Skip to content

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)

8:27:vtrc-api/api/src/connector.js
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(...)

1:22:vtrc-api/api/src/models/core/session/session.js
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 แปลงให้อัตโนมัติ

javascript
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 ที่ติดมาตลอด

58:60:vtrc-api/api/src/models/core/leave/leave.js
        status: {
            type: Sequelize.ENUM('PEDDING_CANCLE', 'PENDING', 'MOREINFO', 'APPROVE', 'REJECT', 'CANCEL'),
            allowNull: true
        },

PEDDING_CANCLE — ผิดทั้ง PEDDING (ควรเป็น PENDING) และ CANCLE (ควรเป็น CANCEL) — แต่เป็น ENUM ที่ sync ลง DB แล้ว ทำให้เป็น public contract ที่ ห้ามแก้ครั้งเดียว

ถ้าจะแก้ต้อง

  1. เพิ่ม ENUM value ใหม่ (PENDING_CANCEL)
  2. migration data ทุก row จาก PEDDING_CANCLEPENDING_CANCEL
  3. แก้ code ทุกที่ที่อ้างถึง
  4. ลบ ENUM value เก่า

ไม่มี migration runner → ต้องทำ manual SQL + coordinate deploy


Associate + addIndex — hook ที่รันหลัง load ทุก model

47:56:vtrc-api/api/src/models/index.js
/** --- 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 ตัว)

101:135:vtrc-api/api/src/lib/model.js
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 ไม่ทำงานจริง

รูปแบบที่เห็นทุกที่

101:124:vtrc-api/api/src/lib/repositories/session/session.js
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

javascript
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 เร็วเกินไป

javascript
const result = await Models.Session.update(...).catch(...)
t.commit()

t.commit() รันเลยหลัง await update — ไม่มี logic รอให้ทุก thing ใน "transaction" เสร็จก่อน

ปัญหาที่ 3 — rollback ใน catch ไม่ทำอะไร

javascript
.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 แก้

ที่ถูกต้อง

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()
}

Sequelize signature คือ Model.update(values, options) ที่ options รวม transaction ข้างใน

ข้อยกเว้น — 3 ที่ที่ถูกต้อง

มีแค่ 3 ที่ใน codebase ที่ใช้ transaction pattern ถูกต้อง

ที่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 ที่นี้ใช้รูปแบบ "ส่ง t เข้า options ของแต่ละ call" ซึ่งถูกต้อง — แต่ inconsistent กับ sibling 88 ที่ที่ผิด


Cross-database transaction — ไม่มี

User model (models/centralize/user/user.js) อยู่ใน db2 แต่บาง repo เขียน transaction ผ่าน db (schema vtrc) แทน

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

javascript
// lib/repositories/excel/excel.js (simplified)
const table = req.params.table  // ← from request
const data = await Models[table].findAll({ where: condition })
javascript
// 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

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

ไม่มี models/core/user ใน repo — models/index.js โหลด core/ เข้า db แล้วโหลด centralize/ เข้า db2 ดังนั้น Models.User คือ centralize model บน db2 โดยตรง ไม่ใช่การทับ core User

javascript
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

  1. อย่าไปพึ่ง transaction pattern ที่มี — มันไม่ทำงาน
  2. เลือก 1 ใน 2 ทาง
    • ใช้ raw SQL transaction (Models.db.query('START TRANSACTION'); ... COMMIT)
    • เขียน compensating action (ถ้า step 2 fail → delete ที่ step 1 สร้าง)
  3. document ไว้ใน comment ว่า "อย่าไปวางใจ transaction ที่นี่"

ถ้าเจอ "row หายไป" หรือ "row ซ้ำ"

อาจจะเป็นเพราะ broken transaction — เช็ก production log หา TRANSECTION_ERROR (typo ใน log ด้วย) ที่บอกว่า write ไหน fail

อย่าใช้ dynamic Models[table]

ถ้าต้องการ query หลาย model ในรูปแบบเดียวกัน — เขียน switch case หรือ map explicit

javascript
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 เดิม


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

ไป บท 3.6 Controller layer survey