@stonyx/orm 0.3.2-beta.33 → 0.3.2-beta.330
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 +377 -30
- package/config/environment.js +111 -12
- package/dist/column-conversion.d.ts +34 -0
- package/dist/column-conversion.js +34 -0
- package/dist/commands.js +34 -0
- package/dist/dynamodb/connection.d.ts +31 -0
- package/dist/dynamodb/connection.js +28 -0
- package/dist/dynamodb/dynamodb-db.d.ts +142 -0
- package/dist/dynamodb/dynamodb-db.js +596 -0
- package/dist/dynamodb/operation-builder.d.ts +76 -0
- package/dist/dynamodb/operation-builder.js +116 -0
- package/dist/dynamodb/type-map.d.ts +31 -0
- package/dist/dynamodb/type-map.js +48 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -2
- package/dist/main.d.ts +1 -0
- package/dist/main.js +29 -1
- package/dist/manage-record.d.ts +4 -0
- package/dist/manage-record.js +95 -34
- package/dist/mysql/connection.d.ts +3 -0
- package/dist/mysql/mysql-db.d.ts +32 -2
- package/dist/mysql/mysql-db.js +239 -45
- package/dist/mysql/query-builder.d.ts +1 -0
- package/dist/mysql/query-builder.js +17 -0
- package/dist/normalize-record-id.d.ts +67 -0
- package/dist/normalize-record-id.js +88 -0
- package/dist/orm-request.d.ts +17 -1
- package/dist/orm-request.js +77 -19
- package/dist/postgres/column-rules.d.ts +33 -0
- package/dist/postgres/column-rules.js +34 -0
- package/dist/postgres/connection.d.ts +1 -0
- package/dist/postgres/connection.js +8 -6
- package/dist/postgres/postgres-db.d.ts +36 -2
- package/dist/postgres/postgres-db.js +244 -45
- package/dist/postgres/query-builder.d.ts +1 -0
- package/dist/postgres/query-builder.js +17 -0
- package/dist/record.js +7 -5
- package/dist/relationships.js +1 -1
- package/dist/serializer.js +38 -2
- package/dist/store.d.ts +13 -1
- package/dist/store.js +65 -6
- package/dist/timescale/query-builder.d.ts +1 -1
- package/dist/timescale/query-builder.js +1 -2
- package/dist/types/orm-types.d.ts +11 -0
- package/package.json +19 -7
- package/src/column-conversion.ts +53 -0
- package/src/commands.ts +43 -0
- package/src/dynamodb/connection.ts +50 -0
- package/src/dynamodb/dynamodb-db.ts +811 -0
- package/src/dynamodb/operation-builder.ts +202 -0
- package/src/dynamodb/type-map.ts +54 -0
- package/src/index.ts +4 -2
- package/src/main.ts +30 -1
- package/src/manage-record.ts +112 -33
- package/src/mysql/connection.ts +3 -0
- package/src/mysql/mysql-db.ts +264 -46
- package/src/mysql/query-builder.ts +21 -0
- package/src/normalize-record-id.ts +88 -0
- package/src/orm-request.ts +109 -18
- package/src/postgres/column-rules.ts +37 -0
- package/src/postgres/connection.ts +10 -6
- package/src/postgres/postgres-db.ts +277 -49
- package/src/postgres/query-builder.ts +21 -0
- package/src/record.ts +8 -5
- package/src/relationships.ts +1 -1
- package/src/serializer.ts +39 -2
- package/src/store.ts +68 -6
- package/src/timescale/query-builder.ts +1 -2
- package/src/types/orm-types.ts +12 -0
- package/src/types/stonyx.d.ts +7 -1
- package/config/environment.ts +0 -91
|
@@ -2,23 +2,41 @@ import { getPool, closePool } from './connection.js';
|
|
|
2
2
|
import { ensureMigrationsTable, getAppliedMigrations, getMigrationFiles, applyMigration, parseMigrationFile } from './migration-runner.js';
|
|
3
3
|
import { introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot } from './schema-introspector.js';
|
|
4
4
|
import { loadLatestSnapshot, detectSchemaDrift } from './migration-generator.js';
|
|
5
|
-
import { buildInsert, buildUpdate, buildDelete, buildSelect, buildVectorSearch, buildHybridSearch } from './query-builder.js';
|
|
5
|
+
import { buildInsert, buildInsertMany, buildUpdate, buildDelete, buildSelect, buildVectorSearch, buildHybridSearch } from './query-builder.js';
|
|
6
6
|
import { store } from '@stonyx/orm';
|
|
7
7
|
import { createRecord } from '../manage-record.js';
|
|
8
8
|
import { confirm } from '@stonyx/utils/prompt';
|
|
9
9
|
import { readFile } from '@stonyx/utils/file';
|
|
10
10
|
import { getPluralName } from '../plural-registry.js';
|
|
11
|
+
import { convertRow } from '../column-conversion.js';
|
|
12
|
+
import { postgresJsonRule } from './column-rules.js';
|
|
11
13
|
import { isDbError } from '../utils.js';
|
|
12
14
|
import config from 'stonyx/config';
|
|
13
15
|
import log from 'stonyx/log';
|
|
14
16
|
import path from 'path';
|
|
17
|
+
const POSTGRES_MAX_BIND_PARAMETERS = 65535;
|
|
18
|
+
const DEFAULT_WRITE_BATCH_SIZE = 500;
|
|
19
|
+
const DEFAULT_WRITE_QUEUE_LIMIT = 10000;
|
|
20
|
+
export class WriteQueueFullError extends Error {
|
|
21
|
+
constructor(limit) {
|
|
22
|
+
super(`PostgresDB write queue is full (${limit} queued writes, writeQueueLimit); the write was rejected`);
|
|
23
|
+
this.name = 'WriteQueueFullError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class WriteQueueClosedError extends Error {
|
|
27
|
+
constructor() {
|
|
28
|
+
super('PostgresDB is shutting down; the write was rejected');
|
|
29
|
+
this.name = 'WriteQueueClosedError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
15
32
|
const defaultDeps = {
|
|
16
33
|
getPool, closePool, ensureMigrationsTable, getAppliedMigrations,
|
|
17
34
|
getMigrationFiles, applyMigration, parseMigrationFile,
|
|
18
35
|
introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot,
|
|
19
36
|
loadLatestSnapshot, detectSchemaDrift,
|
|
20
|
-
buildInsert, buildUpdate, buildDelete, buildSelect, buildVectorSearch, buildHybridSearch,
|
|
21
|
-
createRecord, store, confirm, readFile, getPluralName, config, log, path
|
|
37
|
+
buildInsert, buildInsertMany, buildUpdate, buildDelete, buildSelect, buildVectorSearch, buildHybridSearch,
|
|
38
|
+
createRecord, store, confirm, readFile, getPluralName, config, log, path, process,
|
|
39
|
+
convertRow, postgresJsonRule
|
|
22
40
|
};
|
|
23
41
|
export default class PostgresDB {
|
|
24
42
|
/** PostgreSQL extensions to enable on pool init. Subclasses can override. */
|
|
@@ -30,6 +48,12 @@ export default class PostgresDB {
|
|
|
30
48
|
deps;
|
|
31
49
|
pool;
|
|
32
50
|
pgConfig;
|
|
51
|
+
persistResolvesAfterWrite = true;
|
|
52
|
+
_queuedWrites = [];
|
|
53
|
+
_inFlightWrites = new Set();
|
|
54
|
+
_drainingWrites = null;
|
|
55
|
+
_acceptingWrites = true;
|
|
56
|
+
_exitReport = null;
|
|
33
57
|
constructor(deps = {}) {
|
|
34
58
|
const Ctor = this.constructor;
|
|
35
59
|
if (Ctor.instance)
|
|
@@ -48,6 +72,17 @@ export default class PostgresDB {
|
|
|
48
72
|
this.pool = await this.deps.getPool(this.pgConfig, this.constructor.extensions);
|
|
49
73
|
await this.deps.ensureMigrationsTable(this.pool, this.pgConfig.migrationsTable);
|
|
50
74
|
await this.loadMemoryRecords();
|
|
75
|
+
this._acceptingWrites = true;
|
|
76
|
+
this._registerExitReport();
|
|
77
|
+
}
|
|
78
|
+
get writeBatchSize() {
|
|
79
|
+
return Number(this.pgConfig?.writeBatchSize) || DEFAULT_WRITE_BATCH_SIZE;
|
|
80
|
+
}
|
|
81
|
+
get writeQueueLimit() {
|
|
82
|
+
return Number(this.pgConfig?.writeQueueLimit) || DEFAULT_WRITE_QUEUE_LIMIT;
|
|
83
|
+
}
|
|
84
|
+
get pendingWriteCount() {
|
|
85
|
+
return this._inFlightWrites.size + this._queuedWrites.length;
|
|
51
86
|
}
|
|
52
87
|
async startup() {
|
|
53
88
|
const migrationsPath = this.deps.path.resolve(this.deps.config.rootPath, this.pgConfig.migrationsDir);
|
|
@@ -57,7 +92,17 @@ export default class PostgresDB {
|
|
|
57
92
|
const pending = files.filter(f => !applied.includes(f));
|
|
58
93
|
if (pending.length > 0) {
|
|
59
94
|
this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
|
|
60
|
-
|
|
95
|
+
let shouldApply;
|
|
96
|
+
if (this.pgConfig.autoMigrate === true) {
|
|
97
|
+
shouldApply = true;
|
|
98
|
+
}
|
|
99
|
+
else if (this.pgConfig.autoMigrate === false) {
|
|
100
|
+
shouldApply = false;
|
|
101
|
+
this.deps.log.warn?.(`autoMigrate is false — skipping ${pending.length} pending migration(s).`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
|
|
105
|
+
}
|
|
61
106
|
if (shouldApply) {
|
|
62
107
|
for (const filename of pending) {
|
|
63
108
|
const content = await this.deps.readFile(this.deps.path.join(migrationsPath, filename));
|
|
@@ -76,7 +121,17 @@ export default class PostgresDB {
|
|
|
76
121
|
const schemas = this.deps.introspectModels();
|
|
77
122
|
const modelCount = Object.keys(schemas).length;
|
|
78
123
|
if (modelCount > 0) {
|
|
79
|
-
|
|
124
|
+
let shouldGenerate;
|
|
125
|
+
if (this.pgConfig.autoMigrate === true) {
|
|
126
|
+
shouldGenerate = true;
|
|
127
|
+
}
|
|
128
|
+
else if (this.pgConfig.autoMigrate === false) {
|
|
129
|
+
shouldGenerate = false;
|
|
130
|
+
this.deps.log.warn?.(`autoMigrate is false — skipping initial migration generation for ${modelCount} model(s).`);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
shouldGenerate = await this.deps.confirm(`No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`);
|
|
134
|
+
}
|
|
80
135
|
if (shouldGenerate) {
|
|
81
136
|
const { generateMigration } = await import('./migration-generator.js');
|
|
82
137
|
const result = await generateMigration('initial_setup');
|
|
@@ -104,9 +159,35 @@ export default class PostgresDB {
|
|
|
104
159
|
}
|
|
105
160
|
}
|
|
106
161
|
async shutdown() {
|
|
162
|
+
this._acceptingWrites = false;
|
|
163
|
+
while (this._drainingWrites)
|
|
164
|
+
await this._drainingWrites;
|
|
165
|
+
this._unregisterExitReport();
|
|
107
166
|
await this.deps.closePool();
|
|
108
167
|
this.pool = null;
|
|
109
168
|
}
|
|
169
|
+
_registerExitReport() {
|
|
170
|
+
if (this._exitReport)
|
|
171
|
+
return;
|
|
172
|
+
this._exitReport = () => this._reportUnpersistedWrites();
|
|
173
|
+
this.deps.process.once('exit', this._exitReport);
|
|
174
|
+
}
|
|
175
|
+
_unregisterExitReport() {
|
|
176
|
+
if (!this._exitReport)
|
|
177
|
+
return;
|
|
178
|
+
this.deps.process.removeListener('exit', this._exitReport);
|
|
179
|
+
this._exitReport = null;
|
|
180
|
+
}
|
|
181
|
+
_reportUnpersistedWrites() {
|
|
182
|
+
const lostWrites = [...this._inFlightWrites, ...this._queuedWrites];
|
|
183
|
+
if (lostWrites.length === 0)
|
|
184
|
+
return;
|
|
185
|
+
const lostByModel = new Map();
|
|
186
|
+
for (const { modelName } of lostWrites)
|
|
187
|
+
lostByModel.set(modelName, (lostByModel.get(modelName) ?? 0) + 1);
|
|
188
|
+
const breakdown = [...lostByModel].map(([modelName, count]) => `${modelName}: ${count}`).join(', ');
|
|
189
|
+
this.deps.log.error?.(`[PostgresDB] Process exiting with ${lostWrites.length} queued write(s) not persisted; they are lost (${breakdown})`);
|
|
190
|
+
}
|
|
110
191
|
async save() {
|
|
111
192
|
// No-op: PostgreSQL persists data immediately via persist()
|
|
112
193
|
}
|
|
@@ -352,55 +433,171 @@ export default class PostgresDB {
|
|
|
352
433
|
return rawData;
|
|
353
434
|
}
|
|
354
435
|
async persist(operation, modelName, context, response) {
|
|
355
|
-
// Views are read-only -- no-op for all write operations
|
|
356
436
|
const Orm = (await import('@stonyx/orm')).default;
|
|
357
437
|
if (Orm.instance?.isView?.(modelName))
|
|
358
438
|
return;
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
439
|
+
if (!this._acceptingWrites)
|
|
440
|
+
throw new WriteQueueClosedError();
|
|
441
|
+
if (this._queuedWrites.length >= this.writeQueueLimit)
|
|
442
|
+
throw new WriteQueueFullError(this.writeQueueLimit);
|
|
443
|
+
return new Promise((resolve, reject) => {
|
|
444
|
+
const write = {
|
|
445
|
+
operation, modelName, context, response,
|
|
446
|
+
resolve: () => {
|
|
447
|
+
this._inFlightWrites.delete(write);
|
|
448
|
+
resolve();
|
|
449
|
+
},
|
|
450
|
+
reject: (error) => {
|
|
451
|
+
this._inFlightWrites.delete(write);
|
|
452
|
+
reject(error);
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
this._queuedWrites.push(write);
|
|
456
|
+
this._drainWritesInBackground();
|
|
457
|
+
});
|
|
367
458
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const schema = schemas[modelName];
|
|
371
|
-
if (!schema)
|
|
459
|
+
_drainWritesInBackground() {
|
|
460
|
+
if (this._drainingWrites)
|
|
372
461
|
return;
|
|
462
|
+
this._drainingWrites = this._drainQueuedWrites().finally(() => {
|
|
463
|
+
this._drainingWrites = null;
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
async _drainQueuedWrites() {
|
|
467
|
+
while (this._queuedWrites.length > 0) {
|
|
468
|
+
const writes = this._takeNextWriteGroup();
|
|
469
|
+
writes.forEach(write => this._inFlightWrites.add(write));
|
|
470
|
+
try {
|
|
471
|
+
await this._executeWriteGroup(writes);
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
writes.filter(write => this._inFlightWrites.has(write)).forEach(write => write.reject(error));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
_takeNextWriteGroup() {
|
|
479
|
+
const [first] = this._queuedWrites;
|
|
480
|
+
let groupSize = 1;
|
|
481
|
+
if (first.operation === 'create') {
|
|
482
|
+
while (groupSize < this.writeBatchSize
|
|
483
|
+
&& groupSize < this._queuedWrites.length
|
|
484
|
+
&& this._queuedWrites[groupSize].operation === 'create'
|
|
485
|
+
&& this._queuedWrites[groupSize].modelName === first.modelName)
|
|
486
|
+
groupSize++;
|
|
487
|
+
}
|
|
488
|
+
return this._queuedWrites.splice(0, groupSize);
|
|
489
|
+
}
|
|
490
|
+
async _executeWriteGroup(writes) {
|
|
491
|
+
const [first] = writes;
|
|
492
|
+
if (first.operation === 'create')
|
|
493
|
+
return this._persistCreates(first.modelName, writes);
|
|
494
|
+
try {
|
|
495
|
+
if (first.operation === 'update')
|
|
496
|
+
await this._persistUpdate(first.modelName, first.context, first.response);
|
|
497
|
+
if (first.operation === 'delete')
|
|
498
|
+
await this._persistDelete(first.modelName, first.context);
|
|
499
|
+
first.resolve();
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
first.reject(error);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
async _persistCreates(modelName, writes) {
|
|
506
|
+
const schema = this.deps.introspectModels()[modelName];
|
|
507
|
+
const prepared = [];
|
|
508
|
+
for (const write of writes) {
|
|
509
|
+
try {
|
|
510
|
+
const create = schema ? this._prepareCreate(modelName, schema, write) : null;
|
|
511
|
+
if (create)
|
|
512
|
+
prepared.push(create);
|
|
513
|
+
else
|
|
514
|
+
write.resolve();
|
|
515
|
+
}
|
|
516
|
+
catch (error) {
|
|
517
|
+
write.reject(error);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
for (const chunk of this._chunkWithinBindParameterLimit(prepared)) {
|
|
521
|
+
await this._insertCreates(schema, chunk);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
_prepareCreate(modelName, schema, write) {
|
|
525
|
+
const { context, response } = write;
|
|
373
526
|
const recordId = response?.data?.id;
|
|
374
527
|
const storeRef = this.deps.store;
|
|
375
528
|
const record = recordId != null ? storeRef.get(modelName, isNaN(recordId) ? recordId : parseInt(recordId)) : null;
|
|
376
529
|
if (!record)
|
|
377
|
-
return;
|
|
530
|
+
return null;
|
|
378
531
|
const insertData = this._recordToRow(record, schema, context.rawData);
|
|
379
|
-
// For auto-increment models, remove the pending ID.
|
|
380
|
-
// Check context.rawData (not record.__data) because __pendingSqlId is not a model
|
|
381
|
-
// attribute and gets lost during serialization.
|
|
382
532
|
const isPendingId = context.rawData?.__pendingSqlId === true;
|
|
383
|
-
if (isPendingId)
|
|
533
|
+
if (isPendingId)
|
|
384
534
|
delete insertData.id;
|
|
535
|
+
const row = this.deps.convertRow(insertData, schema.columns, this.deps.postgresJsonRule);
|
|
536
|
+
return { write, modelName, record, row, isPendingId };
|
|
537
|
+
}
|
|
538
|
+
_chunkWithinBindParameterLimit(prepared) {
|
|
539
|
+
const chunks = [];
|
|
540
|
+
let chunk = [];
|
|
541
|
+
let chunkColumns = new Set();
|
|
542
|
+
for (const create of prepared) {
|
|
543
|
+
const columnsWithCreate = new Set([...chunkColumns, ...Object.keys(create.row)]);
|
|
544
|
+
if (chunk.length > 0 && (chunk.length + 1) * columnsWithCreate.size > POSTGRES_MAX_BIND_PARAMETERS) {
|
|
545
|
+
chunks.push(chunk);
|
|
546
|
+
chunk = [];
|
|
547
|
+
chunkColumns = new Set(Object.keys(create.row));
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
chunkColumns = columnsWithCreate;
|
|
551
|
+
}
|
|
552
|
+
chunk.push(create);
|
|
553
|
+
}
|
|
554
|
+
if (chunk.length > 0)
|
|
555
|
+
chunks.push(chunk);
|
|
556
|
+
return chunks;
|
|
557
|
+
}
|
|
558
|
+
async _insertCreates(schema, creates) {
|
|
559
|
+
if (creates.length === 1)
|
|
560
|
+
return this._insertCreatesOneByOne(schema, creates);
|
|
561
|
+
let result;
|
|
562
|
+
try {
|
|
563
|
+
const { sql, values } = this.deps.buildInsertMany(schema.table, creates.map(({ row }) => row));
|
|
564
|
+
result = await this.requirePool().query(sql, values);
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
return this._insertCreatesOneByOne(schema, creates);
|
|
385
568
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
569
|
+
creates.forEach((create, index) => {
|
|
570
|
+
this._adoptGeneratedId(create, result.rows[index]);
|
|
571
|
+
create.write.resolve();
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
async _insertCreatesOneByOne(schema, creates) {
|
|
575
|
+
for (const create of creates) {
|
|
576
|
+
try {
|
|
577
|
+
const { sql, values } = this.deps.buildInsert(schema.table, create.row);
|
|
578
|
+
const result = await this.requirePool().query(sql, values);
|
|
579
|
+
this._adoptGeneratedId(create, result.rows[0]);
|
|
580
|
+
create.write.resolve();
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
create.write.reject(error);
|
|
400
584
|
}
|
|
401
|
-
delete record.__data.__pendingSqlId;
|
|
402
585
|
}
|
|
403
586
|
}
|
|
587
|
+
_adoptGeneratedId({ modelName, record, isPendingId, write }, returnedRow) {
|
|
588
|
+
if (!isPendingId || !returnedRow)
|
|
589
|
+
return;
|
|
590
|
+
const pendingId = record.id;
|
|
591
|
+
const realId = returnedRow.id;
|
|
592
|
+
const modelStore = this.deps.store.get(modelName);
|
|
593
|
+
modelStore.delete(pendingId);
|
|
594
|
+
record.__data.id = realId;
|
|
595
|
+
record.id = realId;
|
|
596
|
+
modelStore.set(realId, record);
|
|
597
|
+
if (write.response?.data)
|
|
598
|
+
write.response.data.id = realId;
|
|
599
|
+
delete record.__data.__pendingSqlId;
|
|
600
|
+
}
|
|
404
601
|
async _persistUpdate(modelName, context, _response) {
|
|
405
602
|
const schemas = this.deps.introspectModels();
|
|
406
603
|
const schema = schemas[modelName];
|
|
@@ -435,7 +632,11 @@ export default class PostgresDB {
|
|
|
435
632
|
return;
|
|
436
633
|
// PostgreSQL doesn't have ON UPDATE CURRENT_TIMESTAMP -- set updated_at manually
|
|
437
634
|
changedData.updated_at = new Date();
|
|
438
|
-
|
|
635
|
+
// Same conversion, same choke point as the create path (#292). Columns
|
|
636
|
+
// appended after the attribute loop (FKs, updated_at) are absent from
|
|
637
|
+
// schema.columns and pass through untouched.
|
|
638
|
+
const row = this.deps.convertRow(changedData, schema.columns, this.deps.postgresJsonRule);
|
|
639
|
+
const { sql, values } = this.deps.buildUpdate(schema.table, id, row);
|
|
439
640
|
await this.requirePool().query(sql, values);
|
|
440
641
|
}
|
|
441
642
|
async _persistDelete(modelName, context) {
|
|
@@ -456,13 +657,11 @@ export default class PostgresDB {
|
|
|
456
657
|
if (data.id !== undefined) {
|
|
457
658
|
row.id = data.id;
|
|
458
659
|
}
|
|
459
|
-
// Attribute columns
|
|
460
|
-
|
|
660
|
+
// Attribute columns. Type conversion is NOT applied here -- it is applied
|
|
661
|
+
// once to the finished payload by the caller, via convertRow (#292).
|
|
662
|
+
for (const col of Object.keys(schema.columns)) {
|
|
461
663
|
if (data[col] !== undefined) {
|
|
462
|
-
|
|
463
|
-
row[col] = pgType === 'JSONB' && typeof data[col] !== 'string'
|
|
464
|
-
? JSON.stringify(data[col])
|
|
465
|
-
: data[col];
|
|
664
|
+
row[col] = data[col];
|
|
466
665
|
}
|
|
467
666
|
}
|
|
468
667
|
// FK columns from relationships
|
|
@@ -12,6 +12,7 @@ interface HybridSearchOptions {
|
|
|
12
12
|
}
|
|
13
13
|
export declare function validateIdentifier(name: string, context?: string): string;
|
|
14
14
|
export declare function buildInsert(table: string, data: Record<string, unknown>): QueryResult;
|
|
15
|
+
export declare function buildInsertMany(table: string, rows: Record<string, unknown>[]): QueryResult;
|
|
15
16
|
export declare function buildUpdate(table: string, id: unknown, data: Record<string, unknown>): QueryResult;
|
|
16
17
|
export declare function buildDelete(table: string, id: unknown): QueryResult;
|
|
17
18
|
export declare function buildSelect(table: string, conditions?: Record<string, unknown>): QueryResult;
|
|
@@ -14,6 +14,23 @@ export function buildInsert(table, data) {
|
|
|
14
14
|
const sql = `INSERT INTO "${table}" (${keys.map(k => `"${k}"`).join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING "id"`;
|
|
15
15
|
return { sql, values };
|
|
16
16
|
}
|
|
17
|
+
export function buildInsertMany(table, rows) {
|
|
18
|
+
validateIdentifier(table, 'table name');
|
|
19
|
+
const columns = [...new Set(rows.flatMap(row => Object.keys(row)))];
|
|
20
|
+
columns.forEach(column => validateIdentifier(column, 'column name'));
|
|
21
|
+
const values = [];
|
|
22
|
+
const tuples = rows.map(row => {
|
|
23
|
+
const cells = columns.map(column => {
|
|
24
|
+
if (!Object.hasOwn(row, column))
|
|
25
|
+
return 'DEFAULT';
|
|
26
|
+
values.push(row[column]);
|
|
27
|
+
return `$${values.length}`;
|
|
28
|
+
});
|
|
29
|
+
return `(${cells.join(', ')})`;
|
|
30
|
+
});
|
|
31
|
+
const sql = `INSERT INTO "${table}" (${columns.map(column => `"${column}"`).join(', ')}) VALUES ${tuples.join(', ')} RETURNING "id"`;
|
|
32
|
+
return { sql, values };
|
|
33
|
+
}
|
|
17
34
|
export function buildUpdate(table, id, data) {
|
|
18
35
|
validateIdentifier(table, 'table name');
|
|
19
36
|
const keys = Object.keys(data);
|
package/dist/record.js
CHANGED
|
@@ -40,11 +40,13 @@ export default class Record {
|
|
|
40
40
|
const records = {};
|
|
41
41
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
42
42
|
if (Array.isArray(childRecord)) {
|
|
43
|
+
// Filter out cleaned records (those with no __model)
|
|
44
|
+
const live = childRecord.filter((r) => r?.__model);
|
|
43
45
|
// Deduplicate by record ID — keep last occurrence (latest data wins)
|
|
44
46
|
const seen = new Set();
|
|
45
47
|
const unique = [];
|
|
46
|
-
for (let i =
|
|
47
|
-
const r =
|
|
48
|
+
for (let i = live.length - 1; i >= 0; i--) {
|
|
49
|
+
const r = live[i];
|
|
48
50
|
if (!seen.has(r.id)) {
|
|
49
51
|
seen.add(r.id);
|
|
50
52
|
unique.push(r);
|
|
@@ -54,7 +56,7 @@ export default class Record {
|
|
|
54
56
|
records[key] = unique.map((r) => r.serialize());
|
|
55
57
|
}
|
|
56
58
|
else {
|
|
57
|
-
records[key] = childRecord?.serialize()
|
|
59
|
+
records[key] = childRecord?.__model ? childRecord.serialize() : null;
|
|
58
60
|
}
|
|
59
61
|
}
|
|
60
62
|
return { ...data, ...records };
|
|
@@ -86,8 +88,8 @@ export default class Record {
|
|
|
86
88
|
if (fields && !fields.has(key))
|
|
87
89
|
continue;
|
|
88
90
|
const relationshipData = Array.isArray(childRecord)
|
|
89
|
-
? childRecord.map((r) => ({ type: r.__model.__name, id: r.id }))
|
|
90
|
-
: childRecord ? { type: childRecord.__model.__name, id: childRecord.id } : null;
|
|
91
|
+
? childRecord.filter((r) => r?.__model).map((r) => ({ type: r.__model.__name, id: r.id }))
|
|
92
|
+
: (childRecord && childRecord.__model) ? { type: childRecord.__model.__name, id: childRecord.id } : null;
|
|
91
93
|
// Dasherize the key for URL paths (e.g., accessLinks -> access-links)
|
|
92
94
|
const dasherizedKey = camelCaseToKebabCase(key);
|
|
93
95
|
relationships[dasherizedKey] = { data: relationshipData };
|
package/dist/relationships.js
CHANGED
|
@@ -38,4 +38,4 @@ export function getPendingRegistry() {
|
|
|
38
38
|
export function getPendingBelongsToRegistry() {
|
|
39
39
|
return relationships.get('pendingBelongsTo');
|
|
40
40
|
}
|
|
41
|
-
export const TYPES = ['global', 'hasMany', 'belongsTo', 'pending'];
|
|
41
|
+
export const TYPES = ['global', 'hasMany', 'belongsTo', 'pending', 'pendingBelongsTo'];
|
package/dist/serializer.js
CHANGED
|
@@ -79,8 +79,44 @@ export default class Serializer {
|
|
|
79
79
|
// Pass relationship key name to handler for pending fulfillment
|
|
80
80
|
const handlerOptions = { ...options, _relationshipKey: key };
|
|
81
81
|
const childRecord = handler(record, data, handlerOptions);
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
// hasMany relationships use a getter so format()/toJSON() always read
|
|
83
|
+
// the live registry array instead of a stale snapshot captured at
|
|
84
|
+
// serialization time. This is critical when child records are created
|
|
85
|
+
// in a later async frame — the belongsTo inverse wiring pushes into
|
|
86
|
+
// the shared registry array, and the getter ensures the parent sees it.
|
|
87
|
+
const isHasMany = handler.__relationshipType === 'hasMany';
|
|
88
|
+
if (isHasMany) {
|
|
89
|
+
// `childRecord` IS the shared registry array — define a getter that
|
|
90
|
+
// always dereferences through the same array reference.
|
|
91
|
+
const registryArray = childRecord;
|
|
92
|
+
Object.defineProperty(rec, key, {
|
|
93
|
+
enumerable: true,
|
|
94
|
+
configurable: true,
|
|
95
|
+
get: () => registryArray,
|
|
96
|
+
set(v) { relatedRecords[key] = v; }
|
|
97
|
+
});
|
|
98
|
+
Object.defineProperty(relatedRecords, key, {
|
|
99
|
+
enumerable: true,
|
|
100
|
+
configurable: true,
|
|
101
|
+
get: () => registryArray,
|
|
102
|
+
set(v) { Object.defineProperty(relatedRecords, key, { value: v, writable: true, enumerable: true, configurable: true }); }
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
rec[key] = childRecord;
|
|
107
|
+
relatedRecords[key] = childRecord;
|
|
108
|
+
// Preserve the raw FK value in __data when the belongsTo handler
|
|
109
|
+
// couldn't resolve the target (e.g., memory:false model not loaded).
|
|
110
|
+
// This allows adapters to read the FK from __data as a fallback
|
|
111
|
+
// when __relationships[key] is null. Only store when `data` is a
|
|
112
|
+
// truthy non-object — i.e., a raw FK string/number that the handler
|
|
113
|
+
// attempted but failed to resolve. When `data` is null/undefined
|
|
114
|
+
// (optional empty relationship) we intentionally skip to preserve
|
|
115
|
+
// the existing behavior of not populating __data for empty FKs.
|
|
116
|
+
if (childRecord === null && data && typeof data !== 'object') {
|
|
117
|
+
parsedData[key] = data;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
84
120
|
continue;
|
|
85
121
|
}
|
|
86
122
|
// Aggregate property handling — use the rawData value, not the aggregate descriptor
|
package/dist/store.d.ts
CHANGED
|
@@ -45,7 +45,19 @@ export default class Store {
|
|
|
45
45
|
*/
|
|
46
46
|
private _isMemoryModel;
|
|
47
47
|
set(key: string, value: Map<number | string, unknown>): void;
|
|
48
|
-
remove(key: string, id?: number | string
|
|
48
|
+
remove(key: string, id?: number | string, options?: {
|
|
49
|
+
_skipAutoPersist?: boolean;
|
|
50
|
+
}): void;
|
|
51
|
+
/**
|
|
52
|
+
* Evict a record from the store with full relationship registry cleanup.
|
|
53
|
+
* The caller retains its reference to the returned record, which is the
|
|
54
|
+
* contract memory:false post-persist eviction relies on.
|
|
55
|
+
*
|
|
56
|
+
* @param registryId - The ID used when the record's relationships were
|
|
57
|
+
* registered. For SQL models with pending IDs, this is the original
|
|
58
|
+
* negative pending ID (before the adapter re-keyed to the real DB ID).
|
|
59
|
+
*/
|
|
60
|
+
evictRecord(modelName: string, id: unknown, registryId?: unknown): void;
|
|
49
61
|
unloadRecord(model: string, id: unknown, options?: UnloadOptions): void;
|
|
50
62
|
unloadAllRecords(model: string, options?: UnloadOptions): void;
|
|
51
63
|
private _removeFromHasManyArrays;
|
package/dist/store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Orm, { relationships } from '@stonyx/orm';
|
|
2
|
-
import { TYPES, getHasManyRegistry, getBelongsToRegistry, getPendingRegistry } from './relationships.js';
|
|
2
|
+
import { TYPES, getHasManyRegistry, getBelongsToRegistry, getPendingRegistry, getPendingBelongsToRegistry } from './relationships.js';
|
|
3
3
|
import ViewResolver from './view-resolver.js';
|
|
4
4
|
function isStoreRecord(value) {
|
|
5
5
|
return typeof value === 'object' && value !== null && '__data' in value;
|
|
@@ -22,7 +22,7 @@ export default class Store {
|
|
|
22
22
|
this.data = new Map();
|
|
23
23
|
}
|
|
24
24
|
get(key, id) {
|
|
25
|
-
if (
|
|
25
|
+
if (id === undefined)
|
|
26
26
|
return this.data.get(key);
|
|
27
27
|
return this.data.get(key)?.get(id);
|
|
28
28
|
}
|
|
@@ -107,13 +107,14 @@ export default class Store {
|
|
|
107
107
|
set(key, value) {
|
|
108
108
|
this.data.set(key, value);
|
|
109
109
|
}
|
|
110
|
-
remove(key, id) {
|
|
110
|
+
remove(key, id, options) {
|
|
111
111
|
// Guard: read-only views cannot have records removed
|
|
112
112
|
if (Orm.instance?.isView?.(key)) {
|
|
113
113
|
throw new Error(`Cannot remove records from read-only view '${key}'`);
|
|
114
114
|
}
|
|
115
|
-
// Auto-persist delete to SQL
|
|
116
|
-
|
|
115
|
+
// Auto-persist delete to SQL (fire-and-forget) — skipped when the
|
|
116
|
+
// request path handles persist itself to avoid double-delete.
|
|
117
|
+
if (id && Orm.instance?.sqlDb && !options?._skipAutoPersist) {
|
|
117
118
|
Orm.instance.sqlDb.persist('delete', key, { recordId: id }, {}).catch((err) => {
|
|
118
119
|
Orm.instance.emitPersistError({
|
|
119
120
|
operation: 'delete',
|
|
@@ -127,6 +128,40 @@ export default class Store {
|
|
|
127
128
|
return this.unloadRecord(key, id);
|
|
128
129
|
this.unloadAllRecords(key);
|
|
129
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Evict a record from the store with full relationship registry cleanup.
|
|
133
|
+
* The caller retains its reference to the returned record, which is the
|
|
134
|
+
* contract memory:false post-persist eviction relies on.
|
|
135
|
+
*
|
|
136
|
+
* @param registryId - The ID used when the record's relationships were
|
|
137
|
+
* registered. For SQL models with pending IDs, this is the original
|
|
138
|
+
* negative pending ID (before the adapter re-keyed to the real DB ID).
|
|
139
|
+
*/
|
|
140
|
+
evictRecord(modelName, id, registryId) {
|
|
141
|
+
const modelStore = this.data.get(modelName);
|
|
142
|
+
if (!modelStore)
|
|
143
|
+
return;
|
|
144
|
+
if (typeof id !== 'string' && typeof id !== 'number')
|
|
145
|
+
return;
|
|
146
|
+
const raw = modelStore.get(id);
|
|
147
|
+
if (!raw || !isStoreRecord(raw))
|
|
148
|
+
return;
|
|
149
|
+
const visited = new Set([`${modelName}:${id}`]);
|
|
150
|
+
// Remove from hasMany arrays and nullify belongsTo references using current ID
|
|
151
|
+
// (the adapter updates record.id, so value-based matches need the current ID)
|
|
152
|
+
this._removeFromHasManyArrays(modelName, id, visited);
|
|
153
|
+
this._nullifyBelongsToReferences(modelName, id, visited);
|
|
154
|
+
// Clean up relationship registry entries using the registry key
|
|
155
|
+
// (belongsTo/hasMany registries were keyed by the ID at registration time,
|
|
156
|
+
// which may differ from the current ID if SQL persist re-keyed the record)
|
|
157
|
+
const cleanupId = registryId ?? id;
|
|
158
|
+
this._cleanupRelationshipRegistries(modelName, cleanupId);
|
|
159
|
+
// If registryId differs from id, also clean with current id as safety net
|
|
160
|
+
if (registryId !== undefined && registryId !== id) {
|
|
161
|
+
this._cleanupRelationshipRegistries(modelName, id);
|
|
162
|
+
}
|
|
163
|
+
modelStore.delete(id);
|
|
164
|
+
}
|
|
130
165
|
unloadRecord(model, id, options = {}) {
|
|
131
166
|
const modelStore = this.data.get(model);
|
|
132
167
|
if (!modelStore) {
|
|
@@ -149,7 +184,6 @@ export default class Store {
|
|
|
149
184
|
this._removeFromHasManyArrays(modelName, recordId, visited);
|
|
150
185
|
this._nullifyBelongsToReferences(modelName, recordId, visited);
|
|
151
186
|
this._cleanupRelationshipRegistries(modelName, recordId);
|
|
152
|
-
recordToUnload.clean();
|
|
153
187
|
this.data.get(modelName)?.delete(recordId);
|
|
154
188
|
}
|
|
155
189
|
}
|
|
@@ -230,6 +264,31 @@ export default class Store {
|
|
|
230
264
|
const pendingMap = getPendingRegistry().get(modelName);
|
|
231
265
|
if (pendingMap)
|
|
232
266
|
pendingMap.delete(recordId);
|
|
267
|
+
// Clean pendingBelongsTo entries in both directions
|
|
268
|
+
const pendingBelongsToMap = getPendingBelongsToRegistry();
|
|
269
|
+
if (pendingBelongsToMap) {
|
|
270
|
+
// Direction 1: evicted record was the TARGET others were waiting for
|
|
271
|
+
const targetEntries = pendingBelongsToMap.get(modelName);
|
|
272
|
+
if (targetEntries)
|
|
273
|
+
targetEntries.delete(recordId);
|
|
274
|
+
// Direction 2: evicted record was the SOURCE with unresolved forward-references
|
|
275
|
+
for (const [, targetIdMap] of pendingBelongsToMap) {
|
|
276
|
+
for (const [targetId, entries] of targetIdMap) {
|
|
277
|
+
if (!Array.isArray(entries))
|
|
278
|
+
continue;
|
|
279
|
+
const filtered = entries.filter((e) => {
|
|
280
|
+
const entry = e;
|
|
281
|
+
return !(entry.sourceModelName === modelName && entry.relationshipId === recordId);
|
|
282
|
+
});
|
|
283
|
+
if (filtered.length === 0) {
|
|
284
|
+
targetIdMap.delete(targetId);
|
|
285
|
+
}
|
|
286
|
+
else if (filtered.length < entries.length) {
|
|
287
|
+
targetIdMap.set(targetId, filtered);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
233
292
|
}
|
|
234
293
|
/**
|
|
235
294
|
* Extracts hasMany and non-bidirectional belongsTo children from a record
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { validateIdentifier, buildInsert, buildUpdate, buildDelete, buildSelect } from '../postgres/query-builder.js';
|
|
1
|
+
export { validateIdentifier, buildInsert, buildInsertMany, buildUpdate, buildDelete, buildSelect } from '../postgres/query-builder.js';
|
|
2
2
|
export declare function validateInterval(interval: string, context?: string): string;
|
|
3
3
|
export declare function validateAggregate(expr: string, context?: string): string;
|
|
4
4
|
interface QueryResult {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
export { validateIdentifier, buildInsert, buildUpdate, buildDelete, buildSelect } from '../postgres/query-builder.js';
|
|
1
|
+
export { validateIdentifier, buildInsert, buildInsertMany, buildUpdate, buildDelete, buildSelect } from '../postgres/query-builder.js';
|
|
3
2
|
import { validateIdentifier } from '../postgres/query-builder.js';
|
|
4
3
|
const SAFE_INTERVAL = /^\d+\s+(microsecond|millisecond|second|minute|hour|day|week|month|year)s?$/i;
|
|
5
4
|
export function validateInterval(interval, context = 'interval') {
|