Skip to content

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

javascript
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

javascript
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

javascript
            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

javascript
/** --- 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

javascript
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

javascript
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

ที่ถูกต้อง

javascript
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.jsoptional t parameter — caller-managed
lib/repositories/bankAccount/vtrcBankAccount.jsเหมือน bank
lib/repositories/study/studyStrategy.jsbulkCreate(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

ModelFileConnectionSchemaPK
Usermodels/centralize/user/user.jsdb2vtrc-centralizeUUID
UserDivisionmodels/centralize/user/userDivision.jsdb2vtrc-centralizeUUID

ไม่มี 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 }


ขั้นตอนถัดไป

ไป 3.1.6 Controller layer survey