3.1.5 · Persistence layer
บทนี้เล่าว่า vtrc-api เก็บข้อมูลอย่างไร — Sequelize pattern, syncModelToTable, db vs db2 และ "transaction bug ระบบ-wide" ที่ทำให้ rollback ไม่ทำงานจริง ทุก citation re-verify ตรงกับโค้ดปัจจุบัน (2026-07-13)
ภาพรวม — 2 schema บน MariaDB host เดียวกัน
MariaDB (host เดียวกัน)
├── schema {vtrc} ← db connection (25 โฟลเดอร์ model ใน models/core/)
│ ├── Sessions
│ ├── LeaveDocuments
│ ├── WDHospitals
│ └── ... (core models)
│
└── schema {vtrc-centralize} ← db2 connection (cross-schema bridge)
├── Users ← Models.User จาก models/centralize/user เท่านั้น
└── UserDivisionsทั้งสอง connection ถูกสร้างใน connector.js (db บรรทัด 8-27, db2 บรรทัด 29-48) ด้วย pool เดียวกัน (max 100, idle 10000ms, acquire 60000ms)
vtrc-api/api/src/connector.js:8-27
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 และภาษาไทยเต็มรูปแบบ ไม่มี dbHr connection ใน connector.js — มีแค่ db และ db2 (ยืนยัน canonical truth)
Model pattern — factory ที่รับ db + Sequelize
ทุก model export default function ที่รับ (db, Sequelize) แล้ว return db.define(...) — ตัวอย่าง Session
vtrc-api/api/src/models/core/session/session.js:1-22
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 ผลข้างเคียง — query ใน repository layer ต้องใช้ [Op.like]: '%value%' เพราะเป็น TEXT ไม่ใช่ JSON column จริง ทำให้เกิด substring collision (ดู 3.1.3)
ENUM — typo ที่ติดมาตลอด
vtrc-api/api/src/models/core/leave/leave.js:59-59
type: Sequelize.ENUM('PEDDING_CANCLE', 'PENDING', 'MOREINFO', 'APPROVE', 'REJECT', 'CANCEL'),PEDDING_CANCLE — ผิดทั้ง PEDDING (ควรเป็น PENDING) และ CANCLE (ควรเป็น CANCEL) แต่เป็น ENUM ที่ sync ลง DB แล้ว เป็น public contract ที่ ห้ามแก้ครั้งเดียว (ต้องเพิ่ม ENUM value ใหม่ → migrate data ทุก row → แก้ code → ลบ ENUM เก่า — ไม่มี migration runner ต้อง manual SQL + coordinate deploy)
Associate + addIndex — hook ที่รันหลัง load ทุก model
vtrc-api/api/src/models/index.js:47-56
/** --- 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 (lib/model.js:17-99) ที่ยัง active — 79 ตัว (comment ออก 2 ตัว: 'BankBranch', 'BankConfigExportCode')
vtrc-api/api/src/lib/model.js:101-135
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 เพิ่ม ไม่ทำ — ไม่เปลี่ยน type ของ column, ไม่ลบ 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 ไม่ทำงานจริง — รูปแบบที่เห็นทุกที่
vtrc-api/api/src/lib/repositories/session/session.js:101-124
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
Model.update(values, options) รับแค่ 2 arguments — Models.Session.update(args, { where: ... }, { transaction: t }) ส่ง { transaction: t } เป็น arg ที่ 3 ที่ถูก drop โดย JavaScript default Sequelize ไม่เคยเห็น transaction option → write ไม่ได้ถูก wrap ใน transaction
ปัญหาที่ 2 — commit เร็วเกินไป
t.commit() รันเลยหลัง await update(...).catch(...) — ไม่มี logic รอให้ทุก thing ใน "transaction" เสร็จก่อน
ปัญหาที่ 3 — rollback ใน catch ไม่ทำอะไร
เนื่องจาก write ไม่ได้อยู่ใน transaction จริง rollback() แค่ rollback transaction ว่าง — write ที่เกิดก่อนหน้าถือว่า commit แล้วอยู่ดี
ผลกระทบ
ถ้าเกิด error ระหว่าง multi-step write (เช่น createWDHospital ที่สร้าง WDHospital + WDSlip + WDHospitalDocument) → step แรก commit แล้ว step ถัดมา fail → data inconsistency ระบบยังใช้งานได้เพราะ pattern นี้ใช้มานานแล้ว มี 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()
}ข้อยกเว้น — 3 ที่ที่ถูกต้อง
| ที่ | 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 ที่นี้ใช้รูปแบบถูกต้อง — inconsistent กับ sibling ~20+ ที่ที่ผิด (severity V3-H1 ใน 3.1.10)
Cross-database transaction — ไม่มี
User model (models/centralize/user/user.js) อยู่ใน db2 แต่บาง repo เขียน transaction ผ่าน db (schema vtrc) แทน — 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 เช่น Models[table].findAll(...) และ Models[modelName].findAll(...) ปกติ caller ส่งค่าจาก trusted source แต่ pattern นี้ทำให้ทุก model ถูก query ได้ถ้า caller เดาชื่อ ถ้าอนาคตมี resolver ที่ forward user input เข้า table หรือ modelName → attacker query model ไหนก็ได้ใน registry (ดู 3.1.7 สำหรับ REST endpoint ที่มี pattern นี้)
ทำไม 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 (ยืนยันจาก filesystem — models/core มี 25 โฟลเดอร์ ไม่มีชื่อ user) models/index.js โหลด core/ เข้า db แล้วโหลด centralize/ เข้า db2 ดังนั้น Models.User คือ centralize model บน db2 โดยตรง ไม่ใช่การทับ core User
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 ที่มี — มันไม่ทำงาน เลือก raw SQL transaction หรือเขียน compensating action แล้ว document ว่า "อย่าไปวางใจ transaction ที่นี่"
ถ้าเจอ "row หายไป" หรือ "row ซ้ำ"
อาจเป็นเพราะ broken transaction — เช็ก production log หา TRANSECTION_ERROR (typo ในโค้ดจริง) ที่บอกว่า write ไหน fail
อย่าใช้ dynamic Models[table]
เขียน switch case หรือ map explicit แทน เช่น const tableMap = { hospital: Models.WDHospital, slip: Models.WDSlip }