velocious 1.0.498 → 1.0.500
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/build/database/drivers/mssql/index.js +79 -2
- package/build/database/record/counter-cache-magnitude.js +240 -0
- package/build/database/record/index.js +26 -0
- package/build/database/record/state-machine.js +25 -3
- package/build/database/tenants/data-copier.js +27 -0
- package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +4 -20
- package/build/src/database/drivers/mssql/index.d.ts +22 -0
- package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/index.js +74 -3
- package/build/src/database/record/counter-cache-magnitude.d.ts +67 -0
- package/build/src/database/record/counter-cache-magnitude.d.ts.map +1 -0
- package/build/src/database/record/counter-cache-magnitude.js +204 -0
- package/build/src/database/record/index.d.ts +18 -0
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +25 -1
- package/build/src/database/record/state-machine.d.ts +2 -0
- package/build/src/database/record/state-machine.d.ts.map +1 -1
- package/build/src/database/record/state-machine.js +21 -4
- package/build/src/database/tenants/data-copier.d.ts +16 -0
- package/build/src/database/tenants/data-copier.d.ts.map +1 -1
- package/build/src/database/tenants/data-copier.js +25 -1
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts +0 -9
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +5 -16
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/database/drivers/mssql/index.js +79 -2
- package/src/database/record/counter-cache-magnitude.js +240 -0
- package/src/database/record/index.js +26 -0
- package/src/database/record/state-machine.js +25 -3
- package/src/database/tenants/data-copier.js +27 -0
- package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +4 -20
package/README.md
CHANGED
|
@@ -403,7 +403,7 @@ npx velocious g:frontend-models
|
|
|
403
403
|
|
|
404
404
|
Frontend-model attributes can usually be declared by name. The generator infers JSDoc typedefs and nullability from backend model columns and translated attribute columns. When an attribute entry needs resource-specific options such as `selectedByDefault: false`, keep only that option in the resource config, for example `{name: "archivedAt", selectedByDefault: false}`; the column type and nullability are still inferred. For computed resource attributes, add a typed `${attributeName}Attribute(model)` method with an `@returns` tag in the backend project's `src` tree. Resource attribute return types take precedence over column types because the resource method controls the serialized value. If Velocious cannot infer a read attribute from a column, generated model accessor, resource method JSDoc, or explicit metadata, generation fails with a clear error instead of emitting a broad fallback type.
|
|
405
405
|
|
|
406
|
-
This creates `src/frontend-models/user.js` (and one file per configured resource). Every generated file — the per-model files
|
|
406
|
+
This creates `src/frontend-models/user.js` (and one file per configured resource). Import each model directly by its file path (e.g. `import User from ".../frontend-models/user.js"`); `src/frontend-models/setup.js` side-effect-imports every model file so they self-register (import it once at app startup). No barrel/`index.js` is generated. Every generated file — the per-model files and `setup.js` here, and the base-model files from `g:base-models` — starts with an auto-generated banner stating it must not be edited manually because changes are overwritten on the next regeneration, and naming the command that regenerates it. Apply changes at their source (resource/model definitions or the generator) and regenerate. Generated classes support:
|
|
407
407
|
|
|
408
408
|
- `await User.find(5)`
|
|
409
409
|
- `await User.findBy({email: "john@example.com"})`
|
|
@@ -148,12 +148,89 @@ export default class VelociousDatabaseDriversMssql extends Base{
|
|
|
148
148
|
return digg(rows, 0, "db_name")
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Disables every foreign key constraint (bulk `NOCHECK`).
|
|
153
|
+
* @returns {Promise<void>} - Resolves when foreign keys are disabled.
|
|
154
|
+
*/
|
|
151
155
|
async disableForeignKeys() {
|
|
152
|
-
await this.
|
|
156
|
+
await this._execConstraintToggle("EXEC sp_MSforeachtable \"ALTER TABLE ? NOCHECK CONSTRAINT all\"", "disableForeignKeys")
|
|
153
157
|
}
|
|
154
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Re-enables and re-validates every foreign key constraint (`WITH CHECK`).
|
|
161
|
+
* @returns {Promise<void>} - Resolves when foreign keys are enabled.
|
|
162
|
+
*/
|
|
155
163
|
async enableForeignKeys() {
|
|
156
|
-
await this.
|
|
164
|
+
await this._execConstraintToggle("EXEC sp_MSforeachtable @command1=\"print '?'\", @command2=\"ALTER TABLE ? WITH CHECK CHECK CONSTRAINT all\"", "enableForeignKeys")
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Runs a bulk constraint-toggle statement. `ALTER TABLE ... NOCHECK/CHECK
|
|
169
|
+
* CONSTRAINT` needs a schema-modification lock on every table, so if the
|
|
170
|
+
* request times out it is almost always blocked by another session that is
|
|
171
|
+
* still holding a lock (a leaked/uncommitted connection). On a timeout,
|
|
172
|
+
* capture which sessions were blocking so the real culprit is named instead
|
|
173
|
+
* of leaving a bare "Request failed to complete in 15000ms".
|
|
174
|
+
* @param {string} sql - Constraint-toggle SQL.
|
|
175
|
+
* @param {string} label - Operation label for the error.
|
|
176
|
+
* @returns {Promise<void>} - Resolves when the toggle completes.
|
|
177
|
+
*/
|
|
178
|
+
async _execConstraintToggle(sql, label) {
|
|
179
|
+
try {
|
|
180
|
+
await this.query(sql)
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error instanceof Error && /Timeout: Request failed to complete/i.test(error.message)) {
|
|
183
|
+
const snapshot = await this._captureBlockingSessionsForDebug().catch(
|
|
184
|
+
(diagError) => `(blocking diagnostics query failed: ${diagError instanceof Error ? diagError.message : diagError})`
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
throw new Error(`${error.message}\n\n[${label} blocked] other user sessions with open transactions or active requests:\n${snapshot}`, {cause: error})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
throw error
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Snapshots the sessions that could be blocking a constraint toggle in THIS
|
|
196
|
+
* database — every session other than this one that holds a lock in `DB_ID()`
|
|
197
|
+
* or is running a request against it — with its last statement, wait state,
|
|
198
|
+
* and blocking session, enough to identify a connection that leaked a lock.
|
|
199
|
+
* Scoped to the current database so a multi-database server does not leak
|
|
200
|
+
* unrelated sessions' SQL into the error and bury the real blocker.
|
|
201
|
+
* @returns {Promise<string>} - JSON snapshot, or a "(none)" marker.
|
|
202
|
+
*/
|
|
203
|
+
async _captureBlockingSessionsForDebug() {
|
|
204
|
+
const rows = await this.query(`
|
|
205
|
+
WITH lock_sessions AS (
|
|
206
|
+
SELECT DISTINCT request_session_id AS session_id
|
|
207
|
+
FROM sys.dm_tran_locks
|
|
208
|
+
WHERE resource_database_id = DB_ID()
|
|
209
|
+
)
|
|
210
|
+
SELECT
|
|
211
|
+
s.session_id AS sessionId,
|
|
212
|
+
s.status AS sessionStatus,
|
|
213
|
+
s.login_time AS loginTime,
|
|
214
|
+
s.last_request_start_time AS lastRequestStart,
|
|
215
|
+
s.last_request_end_time AS lastRequestEnd,
|
|
216
|
+
s.open_transaction_count AS openTransactionCount,
|
|
217
|
+
r.status AS requestStatus,
|
|
218
|
+
r.command AS command,
|
|
219
|
+
r.wait_type AS waitType,
|
|
220
|
+
r.wait_time AS waitTimeMs,
|
|
221
|
+
r.blocking_session_id AS blockingSessionId,
|
|
222
|
+
CAST(ib.event_info AS NVARCHAR(MAX)) AS lastSql
|
|
223
|
+
FROM sys.dm_exec_sessions s
|
|
224
|
+
LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
|
|
225
|
+
OUTER APPLY sys.dm_exec_input_buffer(s.session_id, NULL) ib
|
|
226
|
+
WHERE s.is_user_process = 1
|
|
227
|
+
AND s.session_id <> @@SPID
|
|
228
|
+
AND (s.session_id IN (SELECT session_id FROM lock_sessions) OR r.database_id = DB_ID())
|
|
229
|
+
`)
|
|
230
|
+
|
|
231
|
+
if (rows.length === 0) return "(none — no other session held a lock or ran a request in this database when queried)"
|
|
232
|
+
|
|
233
|
+
return JSON.stringify(rows, null, 2)
|
|
157
234
|
}
|
|
158
235
|
|
|
159
236
|
/**
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reactive counter-cache driven by a per-record magnitude.
|
|
5
|
+
*
|
|
6
|
+
* Maintains a counter column on a `belongsTo` parent as the running sum of each
|
|
7
|
+
* child's magnitude — a small number derived from one source attribute (e.g.
|
|
8
|
+
* `1` while a build's `status` is `running`, else `0`). On every create, update
|
|
9
|
+
* and destroy the change in magnitude is applied to the parent as a single
|
|
10
|
+
* atomic increment (`SET col = col + delta`), and when the foreign key changes
|
|
11
|
+
* the magnitude is moved from the old parent to the new one.
|
|
12
|
+
*
|
|
13
|
+
* Because the counter is derived from the source attribute and diffed on every
|
|
14
|
+
* write, it follows that attribute automatically no matter which code path wrote
|
|
15
|
+
* it — there is no per-transition increment/decrement to forget. The old value is
|
|
16
|
+
* captured in `beforeSave` (Velocious clears `changes()` during the post-update
|
|
17
|
+
* reload) and consumed in `afterSave`, so the increment commits atomically with
|
|
18
|
+
* the row it reflects.
|
|
19
|
+
* @typedef {{
|
|
20
|
+
* belongsTo: string,
|
|
21
|
+
* counterColumn: string,
|
|
22
|
+
* sourceAttribute: string,
|
|
23
|
+
* magnitude: (sourceValue: ?) => number
|
|
24
|
+
* }} MagnitudeCounterCacheDefinition
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Captured pending magnitude change stashed on a record between beforeSave and afterSave.
|
|
29
|
+
* @typedef {{newMagnitude: number, oldMagnitude: number, newParentId: ?, oldParentId: ?}} PendingMagnitudeDelta
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Registers a reactive magnitude counter-cache on a model class.
|
|
34
|
+
* @param {typeof import("./index.js").default} modelClass - Model class to add the counter cache to.
|
|
35
|
+
* @param {MagnitudeCounterCacheDefinition} definition - Counter cache definition.
|
|
36
|
+
* @returns {void}
|
|
37
|
+
*/
|
|
38
|
+
export function registerMagnitudeCounterCache(modelClass, definition) {
|
|
39
|
+
/**
|
|
40
|
+
* Dynamic class.
|
|
41
|
+
* @type {?} */
|
|
42
|
+
const dynamicClass = modelClass
|
|
43
|
+
const registeredFlag = `_magnitudeCounterCacheRegistered_${definition.counterColumn}`
|
|
44
|
+
|
|
45
|
+
// Idempotent per counter column: re-declaring (or subclassing) must not double-register.
|
|
46
|
+
if (Object.prototype.hasOwnProperty.call(dynamicClass, registeredFlag) && dynamicClass[registeredFlag]) {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
dynamicClass[registeredFlag] = true
|
|
51
|
+
|
|
52
|
+
const pendingKey = `_magnitudeCounterCachePending_${definition.counterColumn}`
|
|
53
|
+
|
|
54
|
+
modelClass.beforeSave(async function (record) {
|
|
55
|
+
/**
|
|
56
|
+
* Dynamic record.
|
|
57
|
+
* @type {?} */
|
|
58
|
+
const dynamicRecord = record
|
|
59
|
+
|
|
60
|
+
dynamicRecord[pendingKey] = computePendingMagnitudeDelta(record, definition)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
modelClass.afterSave(async function (record) {
|
|
64
|
+
/**
|
|
65
|
+
* Dynamic record.
|
|
66
|
+
* @type {?} */
|
|
67
|
+
const dynamicRecord = record
|
|
68
|
+
const pending = /** @type {PendingMagnitudeDelta | null} */ (dynamicRecord[pendingKey])
|
|
69
|
+
|
|
70
|
+
dynamicRecord[pendingKey] = null
|
|
71
|
+
|
|
72
|
+
if (pending) {
|
|
73
|
+
await applyPendingMagnitudeDelta(record, definition, pending)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
modelClass.afterDestroy(async function (record) {
|
|
78
|
+
const magnitude = definition.magnitude(record.readAttribute(definition.sourceAttribute))
|
|
79
|
+
const parentId = currentParentId(record, definition)
|
|
80
|
+
|
|
81
|
+
if (parentId && magnitude !== 0) {
|
|
82
|
+
await incrementParentCounter(record, definition, parentId, -magnitude)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Diffs the source attribute (and foreign key) across the pending save to capture
|
|
89
|
+
* how the parent counter should move. Runs in beforeSave, where `changes()` still
|
|
90
|
+
* holds the pre-save values.
|
|
91
|
+
* @param {import("./index.js").default} record - Record being saved.
|
|
92
|
+
* @param {MagnitudeCounterCacheDefinition} definition - Counter cache definition.
|
|
93
|
+
* @returns {PendingMagnitudeDelta} The captured magnitude change.
|
|
94
|
+
*/
|
|
95
|
+
function computePendingMagnitudeDelta(record, definition) {
|
|
96
|
+
const changes = record.changes()
|
|
97
|
+
const sourceColumn = columnNameForAttribute(record, definition.sourceAttribute)
|
|
98
|
+
const foreignKeyColumn = record.getModelClass().getRelationshipByName(definition.belongsTo).getForeignKey()
|
|
99
|
+
|
|
100
|
+
const newSource = record.readAttribute(definition.sourceAttribute)
|
|
101
|
+
const oldSource = oldSourceValueThroughReadCast(record, definition.sourceAttribute, sourceColumn, changes, newSource)
|
|
102
|
+
|
|
103
|
+
const newParentId = currentParentId(record, definition)
|
|
104
|
+
const oldParentId = foreignKeyColumn in changes ? changes[foreignKeyColumn][0] : newParentId
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
newMagnitude: definition.magnitude(newSource),
|
|
108
|
+
oldMagnitude: definition.magnitude(oldSource),
|
|
109
|
+
newParentId,
|
|
110
|
+
oldParentId
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Applies a captured magnitude change to the parent counter(s) as atomic increments.
|
|
116
|
+
* @param {import("./index.js").default} record - Record that was saved.
|
|
117
|
+
* @param {MagnitudeCounterCacheDefinition} definition - Counter cache definition.
|
|
118
|
+
* @param {PendingMagnitudeDelta} pending - The captured magnitude change.
|
|
119
|
+
* @returns {Promise<void>}
|
|
120
|
+
*/
|
|
121
|
+
async function applyPendingMagnitudeDelta(record, definition, pending) {
|
|
122
|
+
const {newMagnitude, oldMagnitude, newParentId, oldParentId} = pending
|
|
123
|
+
|
|
124
|
+
if (oldParentId === newParentId) {
|
|
125
|
+
const delta = newMagnitude - oldMagnitude
|
|
126
|
+
|
|
127
|
+
if (newParentId && delta !== 0) {
|
|
128
|
+
await incrementParentCounter(record, definition, newParentId, delta)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The foreign key moved: take the old magnitude off the old parent and put the
|
|
135
|
+
// new magnitude on the new parent.
|
|
136
|
+
if (oldParentId && oldMagnitude !== 0) {
|
|
137
|
+
await incrementParentCounter(record, definition, oldParentId, -oldMagnitude)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (newParentId && newMagnitude !== 0) {
|
|
141
|
+
await incrementParentCounter(record, definition, newParentId, newMagnitude)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Reads the pre-save value of the source attribute through the normal read/cast
|
|
147
|
+
* path, so `magnitude` receives the old value in the same shape as the new one
|
|
148
|
+
* (e.g. a declared boolean as `true`/`false`, not the raw stored `1`/`0`). Drops
|
|
149
|
+
* the pending change so `readAttribute` falls back to the committed value and
|
|
150
|
+
* applies the same cast a fresh read would, then restores the pending change.
|
|
151
|
+
* @param {import("./index.js").default} record - Record being saved.
|
|
152
|
+
* @param {string} sourceAttribute - Source attribute name.
|
|
153
|
+
* @param {string} sourceColumn - Source column name.
|
|
154
|
+
* @param {Record<string, ?>} changes - The record's pre-save changes (column-keyed).
|
|
155
|
+
* @param {?} currentValue - The current (new) read value, returned when the source did not change.
|
|
156
|
+
* @returns {?} The read-cast pre-save value.
|
|
157
|
+
*/
|
|
158
|
+
function oldSourceValueThroughReadCast(record, sourceAttribute, sourceColumn, changes, currentValue) {
|
|
159
|
+
if (!(sourceColumn in changes)) {
|
|
160
|
+
return currentValue
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Dynamic record.
|
|
165
|
+
* @type {?} */
|
|
166
|
+
const dynamicRecord = record
|
|
167
|
+
const pendingChange = dynamicRecord._changes[sourceColumn]
|
|
168
|
+
|
|
169
|
+
delete dynamicRecord._changes[sourceColumn]
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
return record.readAttribute(sourceAttribute)
|
|
173
|
+
} finally {
|
|
174
|
+
dynamicRecord._changes[sourceColumn] = pendingChange
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Reads the record's current foreign-key value for the counter-cache parent.
|
|
180
|
+
* @param {import("./index.js").default} record - Record whose parent is targeted.
|
|
181
|
+
* @param {MagnitudeCounterCacheDefinition} definition - Counter cache definition.
|
|
182
|
+
* @returns {?} The current foreign-key value.
|
|
183
|
+
*/
|
|
184
|
+
function currentParentId(record, definition) {
|
|
185
|
+
const foreignKeyColumn = record.getModelClass().getRelationshipByName(definition.belongsTo).getForeignKey()
|
|
186
|
+
|
|
187
|
+
return record.readAttribute(attributeNameForColumn(record, foreignKeyColumn))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Atomically adds `amount` to the parent's counter column for one parent row.
|
|
192
|
+
* @param {import("./index.js").default} record - Child record (for connection + relationship metadata).
|
|
193
|
+
* @param {MagnitudeCounterCacheDefinition} definition - Counter cache definition.
|
|
194
|
+
* @param {?} parentId - Parent primary-key value.
|
|
195
|
+
* @param {number} amount - Signed integer to add.
|
|
196
|
+
* @returns {Promise<void>}
|
|
197
|
+
*/
|
|
198
|
+
async function incrementParentCounter(record, definition, parentId, amount) {
|
|
199
|
+
const modelClass = record.getModelClass()
|
|
200
|
+
const relationship = modelClass.getRelationshipByName(definition.belongsTo)
|
|
201
|
+
const parentModelClass = relationship.getTargetModelClass()
|
|
202
|
+
|
|
203
|
+
if (!parentModelClass) {
|
|
204
|
+
throw new Error(`magnitudeCounterCache on ${modelClass.getModelName()} could not resolve the "${definition.belongsTo}" parent model class`)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Update through the PARENT model's connection: the row being modified belongs
|
|
208
|
+
// to the parent, which may live on a different database/tenant than the child.
|
|
209
|
+
const db = parentModelClass.connection()
|
|
210
|
+
const counterColumnSql = db.quoteColumn(definition.counterColumn)
|
|
211
|
+
const truncatedAmount = Math.trunc(amount)
|
|
212
|
+
|
|
213
|
+
await db.query(
|
|
214
|
+
`UPDATE ${db.quoteTable(parentModelClass.tableName())} ` +
|
|
215
|
+
`SET ${counterColumnSql} = COALESCE(${counterColumnSql}, 0) + ${truncatedAmount} ` +
|
|
216
|
+
`WHERE ${db.quoteColumn(relationship.getPrimaryKey())} = ${db.quote(parentId)}`
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Resolves the column name backing an attribute name.
|
|
222
|
+
* @param {import("./index.js").default} record - Record.
|
|
223
|
+
* @param {string} attributeName - Attribute name.
|
|
224
|
+
* @returns {string} The column name for the attribute (falls back to the attribute name).
|
|
225
|
+
*/
|
|
226
|
+
function columnNameForAttribute(record, attributeName) {
|
|
227
|
+
return record.getModelClass().getAttributeNameToColumnNameMap()[attributeName] || attributeName
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Resolves the attribute name backing a column name.
|
|
232
|
+
* @param {import("./index.js").default} record - Record.
|
|
233
|
+
* @param {string} columnName - Column name.
|
|
234
|
+
* @returns {string} The attribute name for the column (falls back to the column name).
|
|
235
|
+
*/
|
|
236
|
+
function attributeNameForColumn(record, columnName) {
|
|
237
|
+
const map = record.getModelClass().getAttributeNameToColumnNameMap()
|
|
238
|
+
|
|
239
|
+
return Object.keys(map).find((attributeName) => map[attributeName] === columnName) || columnName
|
|
240
|
+
}
|
|
@@ -47,6 +47,8 @@ import {defineModelScope} from "../../utils/model-scope.js"
|
|
|
47
47
|
import { normalizeDateStringForWrite, normalizeDateValueForRead, normalizeDateValueForWrite } from "../datetime-storage.js"
|
|
48
48
|
import {formatValue} from "../../utils/format-value.js"
|
|
49
49
|
import {captureCreateAuditChanges, captureUpdateAuditChanges, createAudit, createCreateAudit, createDestroyAudit, createUpdateAudit, registerAuditCallback, registerAuditing, withoutAudit} from "./auditing.js"
|
|
50
|
+
import {registerMagnitudeCounterCache} from "./counter-cache-magnitude.js"
|
|
51
|
+
import {stateMachine} from "./state-machine.js"
|
|
50
52
|
import ValidatorsFormat from "./validators/format.js"
|
|
51
53
|
import ValidatorsLength from "./validators/length.js"
|
|
52
54
|
import ValidatorsPresence from "./validators/presence.js"
|
|
@@ -668,6 +670,30 @@ class VelociousDatabaseRecord {
|
|
|
668
670
|
registerAuditing(this)
|
|
669
671
|
}
|
|
670
672
|
|
|
673
|
+
/**
|
|
674
|
+
* Declares an aasm-style state machine on this model: named states, events
|
|
675
|
+
* (guarded transitions), and enter/exit + before/after transition hooks. See
|
|
676
|
+
* `state-machine.js`. Generates `event()` / `eventAndSave()` / `canEvent()`
|
|
677
|
+
* transition methods per declared event.
|
|
678
|
+
* @param {import("./state-machine.js").StateMachineDefinition} definition - State machine definition.
|
|
679
|
+
* @returns {void}
|
|
680
|
+
*/
|
|
681
|
+
static stateMachine(definition) {
|
|
682
|
+
stateMachine(this, definition)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Maintains a counter column on a `belongsTo` parent as the sum of a per-record
|
|
687
|
+
* magnitude, kept current by atomic increments diffed on every create/update/
|
|
688
|
+
* destroy (and moved between parents when the foreign key changes). See
|
|
689
|
+
* `counter-cache-magnitude.js`.
|
|
690
|
+
* @param {import("./counter-cache-magnitude.js").MagnitudeCounterCacheDefinition} definition - Counter cache definition.
|
|
691
|
+
* @returns {void}
|
|
692
|
+
*/
|
|
693
|
+
static magnitudeCounterCache(definition) {
|
|
694
|
+
registerMagnitudeCounterCache(this, definition)
|
|
695
|
+
}
|
|
696
|
+
|
|
671
697
|
/**
|
|
672
698
|
* Registers a callback invoked after this model writes an audit row for the action.
|
|
673
699
|
* @template {typeof VelociousDatabaseRecord} MC
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* }} StateMachineDefinition
|
|
11
11
|
* @typedef {{
|
|
12
12
|
* beforeEnter?: (model: import("./index.js").default) => void | Promise<void>,
|
|
13
|
-
* afterEnter?: (model: import("./index.js").default) => void | Promise<void
|
|
13
|
+
* afterEnter?: (model: import("./index.js").default) => void | Promise<void>,
|
|
14
|
+
* beforeExit?: (model: import("./index.js").default) => void | Promise<void>,
|
|
15
|
+
* afterExit?: (model: import("./index.js").default) => void | Promise<void>
|
|
14
16
|
* }} StateDefinition
|
|
15
17
|
* @typedef {{
|
|
16
18
|
* from: string | string[],
|
|
@@ -68,6 +70,14 @@ export function stateMachine(ModelClass, definition) {
|
|
|
68
70
|
* @type {?} */
|
|
69
71
|
const dynamicClass = ModelClass
|
|
70
72
|
|
|
73
|
+
// Idempotent: re-declaring on the same class (or a re-evaluated module) must not
|
|
74
|
+
// register the before/after-save transition hooks twice. Guard on an own property
|
|
75
|
+
// so a subclass declaring its own machine is unaffected by the parent's flag.
|
|
76
|
+
if (Object.prototype.hasOwnProperty.call(dynamicClass, "_stateMachineRegistered") && dynamicClass._stateMachineRegistered) {
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
dynamicClass._stateMachineRegistered = true
|
|
71
81
|
dynamicClass._stateMachineDefinition = definition
|
|
72
82
|
dynamicClass._stateMachineColumn = column
|
|
73
83
|
|
|
@@ -229,7 +239,13 @@ export function stateMachine(ModelClass, definition) {
|
|
|
229
239
|
await eventDef.before(model)
|
|
230
240
|
}
|
|
231
241
|
|
|
232
|
-
// Run state
|
|
242
|
+
// Run the exited state's beforeExit, then the entered state's beforeEnter
|
|
243
|
+
const fromStateDefinition = definition.states[pending.from]
|
|
244
|
+
|
|
245
|
+
if (fromStateDefinition?.beforeExit) {
|
|
246
|
+
await fromStateDefinition.beforeExit(model)
|
|
247
|
+
}
|
|
248
|
+
|
|
233
249
|
const stateDefinition = definition.states[pending.to]
|
|
234
250
|
|
|
235
251
|
if (stateDefinition?.beforeEnter) {
|
|
@@ -252,13 +268,19 @@ export function stateMachine(ModelClass, definition) {
|
|
|
252
268
|
// Clear the pending transition now that save is complete
|
|
253
269
|
dynamicModel[PENDING_TRANSITION_KEY] = null
|
|
254
270
|
|
|
255
|
-
// Run state
|
|
271
|
+
// Run the entered state's afterEnter, then the exited state's afterExit
|
|
256
272
|
const stateDefinition = definition.states[pending.to]
|
|
257
273
|
|
|
258
274
|
if (stateDefinition?.afterEnter) {
|
|
259
275
|
await stateDefinition.afterEnter(model)
|
|
260
276
|
}
|
|
261
277
|
|
|
278
|
+
const fromStateDefinition = definition.states[pending.from]
|
|
279
|
+
|
|
280
|
+
if (fromStateDefinition?.afterExit) {
|
|
281
|
+
await fromStateDefinition.afterExit(model)
|
|
282
|
+
}
|
|
283
|
+
|
|
262
284
|
// Run event-level after callback
|
|
263
285
|
const eventDef = definition.events[pending.eventName]
|
|
264
286
|
|
|
@@ -94,6 +94,33 @@ export default class DataCopier {
|
|
|
94
94
|
return sourceRowsByTableName
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Deletes one tenant's rows from the target database, children before parents, with
|
|
99
|
+
* foreign keys disabled inside a single transaction. This is `copy` without the reinsert:
|
|
100
|
+
* the same plan traversal selects the tenant's current target rows and `deleteTargetRows`
|
|
101
|
+
* removes them children-first so the ordering never trips a foreign key. Multi-tenant apps
|
|
102
|
+
* use it to purge a tenant — for example clearing the tenant's master copy in the
|
|
103
|
+
* global/default database on teardown, so foreign keys stop referencing the tenant's
|
|
104
|
+
* about-to-be-removed root row.
|
|
105
|
+
*
|
|
106
|
+
* Returns the deleted rows keyed by table name so the caller can perform any app-specific
|
|
107
|
+
* post-delete work (record-location cleanup, auditing) without that policy leaking into the
|
|
108
|
+
* framework.
|
|
109
|
+
* @param {string} keyValue - Tenant key whose rows should be removed from the target.
|
|
110
|
+
* @returns {Promise<Map<string, Record<string, unknown>[]>>} - The deleted rows by table name.
|
|
111
|
+
*/
|
|
112
|
+
async deleteTenantRows(keyValue) {
|
|
113
|
+
const rowsByTableName = await this.loadRows(this.targetDb, keyValue)
|
|
114
|
+
|
|
115
|
+
await this.targetDb.withDisabledForeignKeys(async () => {
|
|
116
|
+
await this.targetDb.transaction(async () => {
|
|
117
|
+
await this.deleteTargetRows(rowsByTableName)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
return rowsByTableName
|
|
122
|
+
}
|
|
123
|
+
|
|
97
124
|
/**
|
|
98
125
|
* Loads the rows for `keyValue` for every table in the plan from `db`, resolving
|
|
99
126
|
* parent-scoped tables from the ids already selected for their parent table. Used for
|
|
@@ -167,11 +167,10 @@ export default class DbGenerateFrontendModels extends BaseCommand {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
for (const [frontendModelsDir, generatedFiles] of generatedFilesByDirectory) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
console.log("create src/frontend-models/index.js")
|
|
170
|
+
// The index.js barrel is no longer generated — nothing imports it (models are
|
|
171
|
+
// imported by file path, and setup.js performs the registration side-effects).
|
|
172
|
+
// Remove any stale one left from an older generator.
|
|
173
|
+
await fs.rm(`${frontendModelsDir}/index.js`, {force: true})
|
|
175
174
|
|
|
176
175
|
const setupContent = this.buildSetupFileContent(generatedFiles)
|
|
177
176
|
|
|
@@ -632,21 +631,6 @@ export default class DbGenerateFrontendModels extends BaseCommand {
|
|
|
632
631
|
return fileContent
|
|
633
632
|
}
|
|
634
633
|
|
|
635
|
-
/**
|
|
636
|
-
* Runs build index file content.
|
|
637
|
-
* @param {Array<{className: string, fileName: string}>} generatedFiles - Generated model files.
|
|
638
|
-
* @returns {string} - Index file content that imports and re-exports all models.
|
|
639
|
-
*/
|
|
640
|
-
buildIndexFileContent(generatedFiles) {
|
|
641
|
-
let content = generatedFileBanner(FRONTEND_MODELS_REGENERATE_COMMAND)
|
|
642
|
-
|
|
643
|
-
for (const {className, fileName} of generatedFiles) {
|
|
644
|
-
content += `export {default as ${className}} from "./${fileName}"\n`
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
return content
|
|
648
|
-
}
|
|
649
|
-
|
|
650
634
|
/**
|
|
651
635
|
* Runs build setup file content.
|
|
652
636
|
* @param {Array<{className: string, fileName: string}>} generatedFiles - Generated model files.
|
|
@@ -16,6 +16,28 @@ export default class VelociousDatabaseDriversMssql extends Base {
|
|
|
16
16
|
* @returns {Promise<string>} - Resolves with the current database.
|
|
17
17
|
*/
|
|
18
18
|
currentDatabase(): Promise<string>;
|
|
19
|
+
/**
|
|
20
|
+
* Runs a bulk constraint-toggle statement. `ALTER TABLE ... NOCHECK/CHECK
|
|
21
|
+
* CONSTRAINT` needs a schema-modification lock on every table, so if the
|
|
22
|
+
* request times out it is almost always blocked by another session that is
|
|
23
|
+
* still holding a lock (a leaked/uncommitted connection). On a timeout,
|
|
24
|
+
* capture which sessions were blocking so the real culprit is named instead
|
|
25
|
+
* of leaving a bare "Request failed to complete in 15000ms".
|
|
26
|
+
* @param {string} sql - Constraint-toggle SQL.
|
|
27
|
+
* @param {string} label - Operation label for the error.
|
|
28
|
+
* @returns {Promise<void>} - Resolves when the toggle completes.
|
|
29
|
+
*/
|
|
30
|
+
_execConstraintToggle(sql: string, label: string): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Snapshots the sessions that could be blocking a constraint toggle in THIS
|
|
33
|
+
* database — every session other than this one that holds a lock in `DB_ID()`
|
|
34
|
+
* or is running a request against it — with its last statement, wait state,
|
|
35
|
+
* and blocking session, enough to identify a connection that leaked a lock.
|
|
36
|
+
* Scoped to the current database so a multi-database server does not leak
|
|
37
|
+
* unrelated sessions' SQL into the error and bury the real blocker.
|
|
38
|
+
* @returns {Promise<string>} - JSON snapshot, or a "(none)" marker.
|
|
39
|
+
*/
|
|
40
|
+
_captureBlockingSessionsForDebug(): Promise<string>;
|
|
19
41
|
/**
|
|
20
42
|
* Drops the foreign key constraints that reference the given table. MSSQL
|
|
21
43
|
* refuses to drop a table that is still referenced by a FOREIGN KEY
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/database/drivers/mssql/index.js"],"names":[],"mappings":"AAyBA;IAgBM,6CAAqD;IAavD,0DAA+B;IAsBjC;;;;;;OAMG;IACH,gCALW,MAAM,SAEd;QAAuB,WAAW;KAClC,GAAU,MAAM,EAAE,CAOpB;IAoDD;;;OAGG;IACH,mBAFa,OAAO,CAAC,MAAM,CAAC,CAM3B;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/database/drivers/mssql/index.js"],"names":[],"mappings":"AAyBA;IAgBM,6CAAqD;IAavD,0DAA+B;IAsBjC;;;;;;OAMG;IACH,gCALW,MAAM,SAEd;QAAuB,WAAW;KAClC,GAAU,MAAM,EAAE,CAOpB;IAoDD;;;OAGG;IACH,mBAFa,OAAO,CAAC,MAAM,CAAC,CAM3B;IAkBD;;;;;;;;;;OAUG;IACH,2BAJW,MAAM,SACN,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CAgBzB;IAED;;;;;;;;OAQG;IACH,oCAFa,OAAO,CAAC,MAAM,CAAC,CAiC3B;IAeD;;;;;;;;;OASG;IACH,uCAHW,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CAczB;IAsED;;;;OAIG;IACH,cAHW,OAAC,GACC,MAAM,CAUlB;IAwFD,6BAOC;IAOqB,8BAA2C;CA4NlE;iBAhqBgB,YAAY;kBAWX,OAAO;oBADL,cAAc"}
|