3.5 · Persistence layer
บทนี้เล่าว่า vtrc-api เก็บข้อมูลอย่างไร — Sequelize pattern, syncModelToTable, db vs db2 และ "transaction bug ระบบ-wide" ที่ทำให้ rollback ไม่ทำงานจริง
ถ้า debug "ทำไม transaction ไม่ roll back ตอนเกิด error" — อ่านบทนี้ก่อน
ภาพรวม — 2 schema บน MariaDB host เดียวกัน
MariaDB (host เดียวกัน)
├── schema {vtrc} ← db connection (ส่วนใหญ่ของตารางใน syncModels)
│ ├── Sessions
│ ├── LeaveDocuments
│ ├── WDHospitals
│ └── ... (core models)
│
└── schema {vtrc-centralize} ← db2 connection (cross-schema bridge)
├── Users ← Models.User จาก models/centralize/user เท่านั้น
└── UserDivisionsทั้งสอง connection ถูกสร้างใน connector.js:8-48 ด้วย pool เดียวกัน (max 100, idle 10000ms, acquire 60000ms)
const db = new Sequelize(DB_NAME, DB_USERNAME, DB_PASSWORD, {
host: DB_HOST,
port: DB_PORT,
dialect: DB_SCHEMA,
dialectOptions: {
useUTC: false,
// timezone: TIME_ZONE
},
timezone: TIME_ZONE,
define: {
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci'
},
pool: {
max: 100,
min: 0,
acquire: 60000,
idle: 10000
}
})charset เป็น utf8mb4 → support emoji และภาษาไทยเต็มรูปแบบ
Model pattern — factory ที่รับ db + Sequelize
ทุก model เขียนในรูปแบบเดียวกัน — export default function ที่รับ (db, Sequelize) แล้ว return db.define(...)
export default (db, Sequelize) => {
var Session = db.define('Session', {
// attributes
sessionId: {
primaryKey: true,
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
allowNull: false
},
refreshToken: {
type: Sequelize.STRING,
allowNull: false
},
userSession: {
type: Sequelize.TEXT,
allowNull: false,
get: function () {
return JSON.parse(this.getDataValue('userSession'));
},
set: function (value) {
this.setDataValue('userSession', JSON.stringify(value));
},
},JSON column — ใช้ getter/setter
userSession เป็น TEXT ใน DB แต่ code อ่านเขียนเป็น object — Sequelize getter/setter แปลงให้อัตโนมัติ
get: function () {
return JSON.parse(this.getDataValue('userSession'));
},
set: function (value) {
this.setDataValue('userSession', JSON.stringify(value));
},ผลข้างเคียง — query ใน repository layer ต้องใช้ [Op.like]: '%value%' เพราะเป็น TEXT ไม่ใช่ JSON column จริง ทำให้เกิด substring collision (ดู บท 3.3)
ENUM — typo ที่ติดมาตลอด
status: {
type: Sequelize.ENUM('PEDDING_CANCLE', 'PENDING', 'MOREINFO', 'APPROVE', 'REJECT', 'CANCEL'),
allowNull: true
},PEDDING_CANCLE — ผิดทั้ง PEDDING (ควรเป็น PENDING) และ CANCLE (ควรเป็น CANCEL) — แต่เป็น ENUM ที่ sync ลง DB แล้ว ทำให้เป็น public contract ที่ ห้ามแก้ครั้งเดียว
ถ้าจะแก้ต้อง
- เพิ่ม ENUM value ใหม่ (
PENDING_CANCEL) - migration data ทุก row จาก
PEDDING_CANCLE→PENDING_CANCEL - แก้ code ทุกที่ที่อ้างถึง
- ลบ ENUM value เก่า
ไม่มี migration runner → ต้องทำ manual SQL + coordinate deploy
Associate + addIndex — hook ที่รันหลัง load ทุก model
/** --- MANAGE ASSOCIATE --- **/
Object.keys(Models).forEach(modelName => {
if (Models[modelName].associate) {
Models[modelName].associate(Models)
}
if (Models[modelName].addIndex) {
Models[modelName].addIndex(Models)
}
})associate(Models)— ลงทะเบียน relation (belongsTo,hasMany,hasOne)addIndex(Models)— custom hook สำหรับเพิ่ม index (rarely used)
ผลคือ — relation ทั้งหมด resolve หลัง load model ครบ ไม่ต้องกังวลเรื่อง order
syncModelToTable — auto-sync ทุกครั้งที่ boot
lib/model.js:101-135 รัน Models[modelName].sync({ force: false }) เป็น loop ตาม syncModels ที่ยัง active (79 ตัว)
const syncModelToTable = async () => {
const models = syncModels // Object.keys(Models)
const skipModel = ['db', 'Op', 'Sequelize']
const syncTotal = models.length // - skipModel.length
const defaultData = masterData
console.log(`## Syncing model to table total [${syncTotal}]. ##`)
// Sync each models.
for (const i in models) {
const modelName = models[i]
if (skipModel.indexOf(modelName) === -1) {
await Models[modelName].sync({ force: false }).then(synced => {
if (typeof defaultData[modelName] !== 'undefined') {
// Skip when data more than one record.
Models[modelName].findAll({ limit: 1 }).then(rows => {
if (rows.length === 0) {
// Insert default data to table.
Models[modelName].bulkCreate(defaultData[modelName]).then(added => {
console.log(`Data from model "${modelName}" has been added ${defaultData[modelName].length} record(s).`)
return added
})
}
return null
})
}
console.log(`# -- Model#${+i + 1} "${modelName}" has been synced. -- #`)
return synced
}).catch(e => {
console.error('Sync model to table ERROR: ', e)
})
}
}
return 1
}force: false = ไม่ DROP ตาราง — แค่ ALTER ถ้า schema เปลี่ยน
สิ่งที่ sync ทำได้
- สร้างตารางใหม่ถ้ายังไม่มี
- เพิ่ม column ใหม่ถ้า model define เพิ่ม
- ไม่ลบ column เก่า (Sequelize sync ไม่ทำ destructive change โดย default)
สิ่งที่ sync ไม่ทำ
- ไม่เปลี่ยน type ของ column (เช่น STRING → TEXT)
- ไม่ลบ column ที่ model ไม่ define แล้ว
- ไม่ rename column
ผล — schema drift เกิดอย่างเงียบ ทุกครั้งที่ deploy ไม่ครบทุก env ถ้า dev แก้ model แล้วไม่ได้ deploy ไป prod พร้อมกัน schema จะต่างกัน
seed data — defaultData
ถ้า model มีใน defaultData (lib/defaultData.js) และ table ว่าง → bulk insert seed ให้
ปกติใช้สำหรับ master data เช่น role default, menu default
Transaction pattern — bug ระบบ-wide
นี่คือสิ่งที่ต้องรู้ — transaction pattern ที่ใช้ใน repository layer ไม่ทำงานจริง
รูปแบบที่เห็นทุกที่
export const update = async (sessionId, args) => {
try {
return Models.db.transaction().then(async t => {
const result = await Models.Session.update(args, { where: { sessionId: sessionId } }, { transaction: t }).catch(e => {
// ROLLBACK TRANSACTION
if (t) { t.rollback() }
log.query(log.logTextFormat('TRANSECTION_ERROR', 'Method:updateSession', 'QUERY', e, { sessionId: sessionId, ...args }))
responError("DATA_NOT_FOUND", { ...args })
})
t.commit()
if (result[0]) {
return result[0]
} else {
log.query(log.logTextFormat('DATA_NOT_FOUND', 'Method:updateSession', 'QUERY', "", { sessionId: sessionId, ...args }))
responError('DATA_NOT_FOUND', { sessionId })
}
})
}ดูเหมือนมี transaction — แต่แท้จริงแล้วไม่ใช่
ปัญหาที่ 1 — Sequelize signature
Models.Session.update(args, { where: ... }, { transaction: t })Model.update(values, options) รับแค่ 2 arguments — { transaction: t } ที่ส่งเป็น arg ที่ 3 ถูก drop โดย JavaScript default
Sequelize ไม่เคยเห็น transaction option → write ไม่ได้ถูก wrap ใน transaction
ปัญหาที่ 2 — commit เร็วเกินไป
const result = await Models.Session.update(...).catch(...)
t.commit()t.commit() รันเลยหลัง await update — ไม่มี logic รอให้ทุก thing ใน "transaction" เสร็จก่อน
ปัญหาที่ 3 — rollback ใน catch ไม่ทำอะไร
.catch(e => {
if (t) { t.rollback() }
...
})เนื่องจาก write ไม่ได้อยู่ใน transaction จริง rollback() ก็แค่ rollback transaction ว่าง → write ที่เกิดขึ้นก่อนหน้า commitment อยู่แล้ว
ผลกระทบ
ถ้าเกิด error ระหว่าง multi-step write (เช่น createWDHospital ที่สร้าง WDHospital + WDSlip + WDHospitalDocument) → step แรก commit แล้ว step ถัดมา fail → data inconsistency
แต่ระบบยังใช้งานได้เพราะ — pattern นี้ใช้มานานแล้ว, production คุ้นเคยกับ "sometimes data inconsistent" → มี manual cleanup process ที่ HR ทาง Excel upload แก้
ที่ถูกต้อง
const t = await Models.db.transaction()
try {
await Models.Session.update(args, { where: {...}, transaction: t })
// ... other writes with same transaction
await t.commit()
} catch (e) {
await t.rollback()
}Sequelize signature คือ Model.update(values, options) ที่ options รวม transaction ข้างใน
ข้อยกเว้น — 3 ที่ที่ถูกต้อง
มีแค่ 3 ที่ใน codebase ที่ใช้ transaction pattern ถูกต้อง
| ที่ | Pattern |
|---|---|
lib/repositories/bank/bank.js | optional t parameter — caller-managed |
lib/repositories/bankAccount/vtrcBankAccount.js | เหมือน bank |
lib/repositories/study/studyStrategy.js | bulkCreate(args, { transaction: t }) |
3 ที่นี้ใช้รูปแบบ "ส่ง t เข้า options ของแต่ละ call" ซึ่งถูกต้อง — แต่ inconsistent กับ sibling 88 ที่ที่ผิด
Cross-database transaction — ไม่มี
User model (models/centralize/user/user.js) อยู่ใน db2 แต่บาง repo เขียน transaction ผ่าน db (schema vtrc) แทน
// lib/repositories/user/user.js (simplified)
await Models.db.transaction().then(async t => {
await Models.User.update(args, { where: ... }, { transaction: t }) // ❌
t.commit()
})Models.User register บน db2 แต่ transaction สร้างบน db → transaction ไม่ครอบ write อยู่แล้ว (ถึงแม้จะส่งถูก arg ก็ตาม) เพราะ Sequelize transaction bound กับ connection ไม่ cross-database
ถ้าจะ cross-database atomic ต้องใช้ XA transaction (2-phase commit) ซึ่ง MariaDB support แต่ Sequelize interface ไม่ได้เปิด
Dynamic model access — injection risk
excel/excel.js และ runningNo.js ใช้ dynamic indexing ของ Models registry
// lib/repositories/excel/excel.js (simplified)
const table = req.params.table // ← from request
const data = await Models[table].findAll({ where: condition })// lib/repositories/runningNo.js
export const generateDocumentNo = async (modelName, ...) => {
const result = await Models[modelName].findAll(...)
...
}ปกติ caller ส่งค่าจาก trusted source (ไม่ใช่ user input โดยตรง) — แต่ pattern นี้ทำให้ทุก model สามารถถูก query ได้ถ้า caller เดาชื่อ
ถ้าอนาคตมี resolver ที่ forward user input เข้า table หรือ modelName → attacker สามารถอ่าน/เขียน model ไหนก็ได้ใน registry
ทำไม Models.User อยู่บน db2
| Model | File | Connection | Schema | PK |
|---|---|---|---|---|
User | models/centralize/user/user.js | db2 | vtrc-centralize | UUID |
UserDivision | models/centralize/user/userDivision.js | db2 | vtrc-centralize | UUID |
ไม่มี models/core/user ใน repo — models/index.js โหลด core/ เข้า db แล้วโหลด centralize/ เข้า db2 ดังนั้น Models.User คือ centralize model บน db2 โดยตรง ไม่ใช่การทับ core User
walk(path.join(__dirname, "centralize"))
.forEach(file => {
const model = require(...).default(db2, ...);
Models[model.name] = model // User / UserDivision บน db2
})lib/repositories/user/user.js ที่เรียก Models.User.create(...) จึงเขียนที่ schema vtrc-centralize — อย่าเข้าใจผิดว่าเป็นตารางบน schema vtrc
เคล็ดลับตอนทำงานจริง
ถ้าเพิ่ม column ใหม่ใน model
แค่ define ใน model file → deploy → syncModelToTable รัน ALTER TABLE ให้อัตโนมัติ แต่
- ใส่
allowNull: trueหรือdefaultValueเสมอ — ไม่งั้น sync จะ fail เพราะ row เดิมไม่มีค่า - อย่าลืม deploy ทุก env พร้อมกัน — schema drift เกิดเงียบถ้า deploy ไม่ครบ
- อย่าเปลี่ยน type ของ column — sync ไม่ทำ ต้อง manual ALTER
ถ้าจะทำ multi-step write ที่ต้อง atomic
- อย่าไปพึ่ง transaction pattern ที่มี — มันไม่ทำงาน
- เลือก 1 ใน 2 ทาง
- ใช้ raw SQL transaction (
Models.db.query('START TRANSACTION'); ... COMMIT) - เขียน compensating action (ถ้า step 2 fail → delete ที่ step 1 สร้าง)
- ใช้ raw SQL transaction (
- document ไว้ใน comment ว่า "อย่าไปวางใจ transaction ที่นี่"
ถ้าเจอ "row หายไป" หรือ "row ซ้ำ"
อาจจะเป็นเพราะ broken transaction — เช็ก production log หา TRANSECTION_ERROR (typo ใน log ด้วย) ที่บอกว่า write ไหน fail
อย่าใช้ dynamic Models[table]
ถ้าต้องการ query หลาย model ในรูปแบบเดียวกัน — เขียน switch case หรือ map explicit
const tableMap = { hospital: Models.WDHospital, slip: Models.WDSlip }
const Model = tableMap[req.params.table]
if (!Model) throw new Error('Invalid table')ถ้า sync fail ตอน boot
ดู console log หา Sync model to table ERROR: ... → model ไหน fail ข้อความจะบอก ปกติเป็นเพราะ ENUM value หรือ type ไม่ compatible กับ column เดิม