@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,12 +2,14 @@ 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';
|
|
@@ -26,6 +28,43 @@ interface PersistResponse {
|
|
|
26
28
|
data?: { id: unknown };
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
interface QueuedWrite {
|
|
32
|
+
operation: string;
|
|
33
|
+
modelName: string;
|
|
34
|
+
context: PersistContext;
|
|
35
|
+
response: PersistResponse;
|
|
36
|
+
resolve: () => void;
|
|
37
|
+
reject: (error: unknown) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface PreparedCreate {
|
|
41
|
+
write: QueuedWrite;
|
|
42
|
+
modelName: string;
|
|
43
|
+
record: OrmRecord;
|
|
44
|
+
row: Record<string, unknown>;
|
|
45
|
+
isPendingId: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type ExitEmitter = Pick<NodeJS.Process, 'once' | 'removeListener'>;
|
|
49
|
+
|
|
50
|
+
const POSTGRES_MAX_BIND_PARAMETERS = 65535;
|
|
51
|
+
const DEFAULT_WRITE_BATCH_SIZE = 500;
|
|
52
|
+
const DEFAULT_WRITE_QUEUE_LIMIT = 10000;
|
|
53
|
+
|
|
54
|
+
export class WriteQueueFullError extends Error {
|
|
55
|
+
constructor(limit: number) {
|
|
56
|
+
super(`PostgresDB write queue is full (${limit} queued writes, writeQueueLimit); the write was rejected`);
|
|
57
|
+
this.name = 'WriteQueueFullError';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class WriteQueueClosedError extends Error {
|
|
62
|
+
constructor() {
|
|
63
|
+
super('PostgresDB is shutting down; the write was rejected');
|
|
64
|
+
this.name = 'WriteQueueClosedError';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
29
68
|
interface SearchResult {
|
|
30
69
|
record: OrmRecord;
|
|
31
70
|
distance: number;
|
|
@@ -51,6 +90,7 @@ interface PostgresDeps {
|
|
|
51
90
|
loadLatestSnapshot: typeof loadLatestSnapshot;
|
|
52
91
|
detectSchemaDrift: typeof detectSchemaDrift;
|
|
53
92
|
buildInsert: typeof buildInsert;
|
|
93
|
+
buildInsertMany: typeof buildInsertMany;
|
|
54
94
|
buildUpdate: typeof buildUpdate;
|
|
55
95
|
buildDelete: typeof buildDelete;
|
|
56
96
|
buildSelect: typeof buildSelect;
|
|
@@ -61,9 +101,12 @@ interface PostgresDeps {
|
|
|
61
101
|
confirm: typeof confirm;
|
|
62
102
|
readFile: typeof readFile;
|
|
63
103
|
getPluralName: typeof getPluralName;
|
|
104
|
+
convertRow: typeof convertRow;
|
|
105
|
+
postgresJsonRule: typeof postgresJsonRule;
|
|
64
106
|
config: typeof config;
|
|
65
107
|
log: typeof log;
|
|
66
108
|
path: typeof path;
|
|
109
|
+
process: ExitEmitter;
|
|
67
110
|
[key: string]: unknown;
|
|
68
111
|
}
|
|
69
112
|
|
|
@@ -72,8 +115,9 @@ const defaultDeps: PostgresDeps = {
|
|
|
72
115
|
getMigrationFiles, applyMigration, parseMigrationFile,
|
|
73
116
|
introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot,
|
|
74
117
|
loadLatestSnapshot, detectSchemaDrift,
|
|
75
|
-
buildInsert, buildUpdate, buildDelete, buildSelect, buildVectorSearch, buildHybridSearch,
|
|
76
|
-
createRecord, store, confirm, readFile, getPluralName, config, log, path
|
|
118
|
+
buildInsert, buildInsertMany, buildUpdate, buildDelete, buildSelect, buildVectorSearch, buildHybridSearch,
|
|
119
|
+
createRecord, store, confirm, readFile, getPluralName, config, log, path, process,
|
|
120
|
+
convertRow, postgresJsonRule
|
|
77
121
|
};
|
|
78
122
|
|
|
79
123
|
export default class PostgresDB {
|
|
@@ -90,6 +134,13 @@ export default class PostgresDB {
|
|
|
90
134
|
pool!: Pool | null;
|
|
91
135
|
pgConfig!: Record<string, unknown>;
|
|
92
136
|
|
|
137
|
+
readonly persistResolvesAfterWrite = true;
|
|
138
|
+
private _queuedWrites: QueuedWrite[] = [];
|
|
139
|
+
private _inFlightWrites = new Set<QueuedWrite>();
|
|
140
|
+
private _drainingWrites: Promise<void> | null = null;
|
|
141
|
+
private _acceptingWrites = true;
|
|
142
|
+
private _exitReport: (() => void) | null = null;
|
|
143
|
+
|
|
93
144
|
constructor(deps: Partial<PostgresDeps> = {}) {
|
|
94
145
|
const Ctor = this.constructor as typeof PostgresDB;
|
|
95
146
|
if (Ctor.instance) return Ctor.instance;
|
|
@@ -112,6 +163,20 @@ export default class PostgresDB {
|
|
|
112
163
|
);
|
|
113
164
|
await this.deps.ensureMigrationsTable(this.pool, this.pgConfig.migrationsTable as string | undefined);
|
|
114
165
|
await this.loadMemoryRecords();
|
|
166
|
+
this._acceptingWrites = true;
|
|
167
|
+
this._registerExitReport();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
get writeBatchSize(): number {
|
|
171
|
+
return Number(this.pgConfig?.writeBatchSize) || DEFAULT_WRITE_BATCH_SIZE;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
get writeQueueLimit(): number {
|
|
175
|
+
return Number(this.pgConfig?.writeQueueLimit) || DEFAULT_WRITE_QUEUE_LIMIT;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
get pendingWriteCount(): number {
|
|
179
|
+
return this._inFlightWrites.size + this._queuedWrites.length;
|
|
115
180
|
}
|
|
116
181
|
|
|
117
182
|
async startup(): Promise<void> {
|
|
@@ -125,7 +190,15 @@ export default class PostgresDB {
|
|
|
125
190
|
if (pending.length > 0) {
|
|
126
191
|
this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
|
|
127
192
|
|
|
128
|
-
|
|
193
|
+
let shouldApply: boolean;
|
|
194
|
+
if (this.pgConfig.autoMigrate === true) {
|
|
195
|
+
shouldApply = true;
|
|
196
|
+
} else if (this.pgConfig.autoMigrate === false) {
|
|
197
|
+
shouldApply = false;
|
|
198
|
+
this.deps.log.warn?.(`autoMigrate is false — skipping ${pending.length} pending migration(s).`);
|
|
199
|
+
} else {
|
|
200
|
+
shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
|
|
201
|
+
}
|
|
129
202
|
|
|
130
203
|
if (shouldApply) {
|
|
131
204
|
for (const filename of pending) {
|
|
@@ -146,9 +219,17 @@ export default class PostgresDB {
|
|
|
146
219
|
const modelCount = Object.keys(schemas).length;
|
|
147
220
|
|
|
148
221
|
if (modelCount > 0) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
222
|
+
let shouldGenerate: boolean;
|
|
223
|
+
if (this.pgConfig.autoMigrate === true) {
|
|
224
|
+
shouldGenerate = true;
|
|
225
|
+
} else if (this.pgConfig.autoMigrate === false) {
|
|
226
|
+
shouldGenerate = false;
|
|
227
|
+
this.deps.log.warn?.(`autoMigrate is false — skipping initial migration generation for ${modelCount} model(s).`);
|
|
228
|
+
} else {
|
|
229
|
+
shouldGenerate = await this.deps.confirm(
|
|
230
|
+
`No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
152
233
|
|
|
153
234
|
if (shouldGenerate) {
|
|
154
235
|
const { generateMigration } = await import('./migration-generator.js');
|
|
@@ -181,10 +262,36 @@ export default class PostgresDB {
|
|
|
181
262
|
}
|
|
182
263
|
|
|
183
264
|
async shutdown(): Promise<void> {
|
|
265
|
+
this._acceptingWrites = false;
|
|
266
|
+
while (this._drainingWrites) await this._drainingWrites;
|
|
267
|
+
this._unregisterExitReport();
|
|
184
268
|
await this.deps.closePool();
|
|
185
269
|
this.pool = null;
|
|
186
270
|
}
|
|
187
271
|
|
|
272
|
+
private _registerExitReport(): void {
|
|
273
|
+
if (this._exitReport) return;
|
|
274
|
+
this._exitReport = () => this._reportUnpersistedWrites();
|
|
275
|
+
this.deps.process.once('exit', this._exitReport);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private _unregisterExitReport(): void {
|
|
279
|
+
if (!this._exitReport) return;
|
|
280
|
+
this.deps.process.removeListener('exit', this._exitReport);
|
|
281
|
+
this._exitReport = null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private _reportUnpersistedWrites(): void {
|
|
285
|
+
const lostWrites = [...this._inFlightWrites, ...this._queuedWrites];
|
|
286
|
+
if (lostWrites.length === 0) return;
|
|
287
|
+
|
|
288
|
+
const lostByModel = new Map<string, number>();
|
|
289
|
+
for (const { modelName } of lostWrites) lostByModel.set(modelName, (lostByModel.get(modelName) ?? 0) + 1);
|
|
290
|
+
const breakdown = [...lostByModel].map(([modelName, count]) => `${modelName}: ${count}`).join(', ');
|
|
291
|
+
|
|
292
|
+
this.deps.log.error?.(`[PostgresDB] Process exiting with ${lostWrites.length} queued write(s) not persisted; they are lost (${breakdown})`);
|
|
293
|
+
}
|
|
294
|
+
|
|
188
295
|
async save(): Promise<void> {
|
|
189
296
|
// No-op: PostgreSQL persists data immediately via persist()
|
|
190
297
|
}
|
|
@@ -464,67 +571,185 @@ export default class PostgresDB {
|
|
|
464
571
|
}
|
|
465
572
|
|
|
466
573
|
async persist(operation: string, modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
|
|
467
|
-
// Views are read-only -- no-op for all write operations
|
|
468
574
|
const Orm = (await import('@stonyx/orm')).default;
|
|
469
575
|
if ((Orm.instance as { isView?: (name: string) => boolean })?.isView?.(modelName)) return;
|
|
470
576
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
577
|
+
if (!this._acceptingWrites) throw new WriteQueueClosedError();
|
|
578
|
+
if (this._queuedWrites.length >= this.writeQueueLimit) throw new WriteQueueFullError(this.writeQueueLimit);
|
|
579
|
+
|
|
580
|
+
return new Promise<void>((resolve, reject) => {
|
|
581
|
+
const write: QueuedWrite = {
|
|
582
|
+
operation, modelName, context, response,
|
|
583
|
+
resolve: () => {
|
|
584
|
+
this._inFlightWrites.delete(write);
|
|
585
|
+
resolve();
|
|
586
|
+
},
|
|
587
|
+
reject: (error: unknown) => {
|
|
588
|
+
this._inFlightWrites.delete(write);
|
|
589
|
+
reject(error);
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
this._queuedWrites.push(write);
|
|
593
|
+
this._drainWritesInBackground();
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private _drainWritesInBackground(): void {
|
|
598
|
+
if (this._drainingWrites) return;
|
|
599
|
+
this._drainingWrites = this._drainQueuedWrites().finally(() => {
|
|
600
|
+
this._drainingWrites = null;
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private async _drainQueuedWrites(): Promise<void> {
|
|
605
|
+
while (this._queuedWrites.length > 0) {
|
|
606
|
+
const writes = this._takeNextWriteGroup();
|
|
607
|
+
writes.forEach(write => this._inFlightWrites.add(write));
|
|
608
|
+
try {
|
|
609
|
+
await this._executeWriteGroup(writes);
|
|
610
|
+
} catch (error) {
|
|
611
|
+
writes.filter(write => this._inFlightWrites.has(write)).forEach(write => write.reject(error));
|
|
612
|
+
}
|
|
478
613
|
}
|
|
479
614
|
}
|
|
480
615
|
|
|
481
|
-
private
|
|
482
|
-
const
|
|
483
|
-
|
|
616
|
+
private _takeNextWriteGroup(): QueuedWrite[] {
|
|
617
|
+
const [first] = this._queuedWrites;
|
|
618
|
+
let groupSize = 1;
|
|
619
|
+
|
|
620
|
+
if (first.operation === 'create') {
|
|
621
|
+
while (
|
|
622
|
+
groupSize < this.writeBatchSize
|
|
623
|
+
&& groupSize < this._queuedWrites.length
|
|
624
|
+
&& this._queuedWrites[groupSize].operation === 'create'
|
|
625
|
+
&& this._queuedWrites[groupSize].modelName === first.modelName
|
|
626
|
+
) groupSize++;
|
|
627
|
+
}
|
|
484
628
|
|
|
485
|
-
|
|
629
|
+
return this._queuedWrites.splice(0, groupSize);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private async _executeWriteGroup(writes: QueuedWrite[]): Promise<void> {
|
|
633
|
+
const [first] = writes;
|
|
634
|
+
if (first.operation === 'create') return this._persistCreates(first.modelName, writes);
|
|
635
|
+
|
|
636
|
+
try {
|
|
637
|
+
if (first.operation === 'update') await this._persistUpdate(first.modelName, first.context, first.response);
|
|
638
|
+
if (first.operation === 'delete') await this._persistDelete(first.modelName, first.context);
|
|
639
|
+
first.resolve();
|
|
640
|
+
} catch (error) {
|
|
641
|
+
first.reject(error);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private async _persistCreates(modelName: string, writes: QueuedWrite[]): Promise<void> {
|
|
646
|
+
const schema = this.deps.introspectModels()[modelName];
|
|
647
|
+
const prepared: PreparedCreate[] = [];
|
|
648
|
+
|
|
649
|
+
for (const write of writes) {
|
|
650
|
+
try {
|
|
651
|
+
const create = schema ? this._prepareCreate(modelName, schema, write) : null;
|
|
652
|
+
if (create) prepared.push(create);
|
|
653
|
+
else write.resolve();
|
|
654
|
+
} catch (error) {
|
|
655
|
+
write.reject(error);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
for (const chunk of this._chunkWithinBindParameterLimit(prepared)) {
|
|
660
|
+
await this._insertCreates(schema, chunk);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
486
663
|
|
|
664
|
+
private _prepareCreate(modelName: string, schema: ModelSchema, write: QueuedWrite): PreparedCreate | null {
|
|
665
|
+
const { context, response } = write;
|
|
487
666
|
const recordId = response?.data?.id;
|
|
488
667
|
const storeRef = this.deps.store as unknown as {
|
|
489
668
|
get(name: string, id: unknown): OrmRecord | null;
|
|
490
669
|
};
|
|
491
670
|
const record = recordId != null ? storeRef.get(modelName, isNaN(recordId as number) ? recordId : parseInt(recordId as string)) : null;
|
|
492
671
|
|
|
493
|
-
if (!record) return;
|
|
672
|
+
if (!record) return null;
|
|
494
673
|
|
|
495
674
|
const insertData = this._recordToRow(record, schema, context.rawData);
|
|
496
|
-
|
|
497
|
-
// For auto-increment models, remove the pending ID.
|
|
498
|
-
// Check context.rawData (not record.__data) because __pendingSqlId is not a model
|
|
499
|
-
// attribute and gets lost during serialization.
|
|
500
675
|
const isPendingId = context.rawData?.__pendingSqlId === true;
|
|
501
676
|
|
|
502
|
-
if (isPendingId)
|
|
503
|
-
delete insertData.id;
|
|
504
|
-
}
|
|
677
|
+
if (isPendingId) delete insertData.id;
|
|
505
678
|
|
|
506
|
-
const
|
|
679
|
+
const row = this.deps.convertRow(insertData, schema.columns, this.deps.postgresJsonRule);
|
|
507
680
|
|
|
508
|
-
|
|
681
|
+
return { write, modelName, record, row, isPendingId };
|
|
682
|
+
}
|
|
509
683
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
const modelStore = (this.deps.store as unknown as { get(name: string): Map<unknown, unknown> }).get(modelName);
|
|
684
|
+
private _chunkWithinBindParameterLimit(prepared: PreparedCreate[]): PreparedCreate[][] {
|
|
685
|
+
const chunks: PreparedCreate[][] = [];
|
|
686
|
+
let chunk: PreparedCreate[] = [];
|
|
687
|
+
let chunkColumns = new Set<string>();
|
|
515
688
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
record.id = realId;
|
|
519
|
-
modelStore.set(realId, record);
|
|
689
|
+
for (const create of prepared) {
|
|
690
|
+
const columnsWithCreate = new Set([...chunkColumns, ...Object.keys(create.row)]);
|
|
520
691
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
692
|
+
if (chunk.length > 0 && (chunk.length + 1) * columnsWithCreate.size > POSTGRES_MAX_BIND_PARAMETERS) {
|
|
693
|
+
chunks.push(chunk);
|
|
694
|
+
chunk = [];
|
|
695
|
+
chunkColumns = new Set(Object.keys(create.row));
|
|
696
|
+
} else {
|
|
697
|
+
chunkColumns = columnsWithCreate;
|
|
524
698
|
}
|
|
525
699
|
|
|
526
|
-
|
|
700
|
+
chunk.push(create);
|
|
527
701
|
}
|
|
702
|
+
|
|
703
|
+
if (chunk.length > 0) chunks.push(chunk);
|
|
704
|
+
return chunks;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private async _insertCreates(schema: ModelSchema, creates: PreparedCreate[]): Promise<void> {
|
|
708
|
+
if (creates.length === 1) return this._insertCreatesOneByOne(schema, creates);
|
|
709
|
+
|
|
710
|
+
let result: { rows: Record<string, unknown>[] };
|
|
711
|
+
|
|
712
|
+
try {
|
|
713
|
+
const { sql, values } = this.deps.buildInsertMany(schema.table, creates.map(({ row }) => row));
|
|
714
|
+
result = await this.requirePool().query(sql, values);
|
|
715
|
+
} catch {
|
|
716
|
+
return this._insertCreatesOneByOne(schema, creates);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
creates.forEach((create, index) => {
|
|
720
|
+
this._adoptGeneratedId(create, result.rows[index]);
|
|
721
|
+
create.write.resolve();
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private async _insertCreatesOneByOne(schema: ModelSchema, creates: PreparedCreate[]): Promise<void> {
|
|
726
|
+
for (const create of creates) {
|
|
727
|
+
try {
|
|
728
|
+
const { sql, values } = this.deps.buildInsert(schema.table, create.row);
|
|
729
|
+
const result = await this.requirePool().query(sql, values);
|
|
730
|
+
this._adoptGeneratedId(create, result.rows[0]);
|
|
731
|
+
create.write.resolve();
|
|
732
|
+
} catch (error) {
|
|
733
|
+
create.write.reject(error);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private _adoptGeneratedId({ modelName, record, isPendingId, write }: PreparedCreate, returnedRow: Record<string, unknown> | undefined): void {
|
|
739
|
+
if (!isPendingId || !returnedRow) return;
|
|
740
|
+
|
|
741
|
+
const pendingId = record.id;
|
|
742
|
+
const realId = returnedRow.id as string | number;
|
|
743
|
+
const modelStore = (this.deps.store as unknown as { get(name: string): Map<unknown, unknown> }).get(modelName);
|
|
744
|
+
|
|
745
|
+
modelStore.delete(pendingId);
|
|
746
|
+
record.__data.id = realId;
|
|
747
|
+
record.id = realId;
|
|
748
|
+
modelStore.set(realId, record);
|
|
749
|
+
|
|
750
|
+
if (write.response?.data) write.response.data.id = realId;
|
|
751
|
+
|
|
752
|
+
delete record.__data.__pendingSqlId;
|
|
528
753
|
}
|
|
529
754
|
|
|
530
755
|
private async _persistUpdate(modelName: string, context: PersistContext, _response: PersistResponse): Promise<void> {
|
|
@@ -568,7 +793,12 @@ export default class PostgresDB {
|
|
|
568
793
|
// PostgreSQL doesn't have ON UPDATE CURRENT_TIMESTAMP -- set updated_at manually
|
|
569
794
|
changedData.updated_at = new Date();
|
|
570
795
|
|
|
571
|
-
|
|
796
|
+
// Same conversion, same choke point as the create path (#292). Columns
|
|
797
|
+
// appended after the attribute loop (FKs, updated_at) are absent from
|
|
798
|
+
// schema.columns and pass through untouched.
|
|
799
|
+
const row = this.deps.convertRow(changedData, schema.columns, this.deps.postgresJsonRule);
|
|
800
|
+
|
|
801
|
+
const { sql, values } = this.deps.buildUpdate(schema.table, id, row);
|
|
572
802
|
await this.requirePool().query(sql, values);
|
|
573
803
|
}
|
|
574
804
|
|
|
@@ -594,13 +824,11 @@ export default class PostgresDB {
|
|
|
594
824
|
row.id = data.id;
|
|
595
825
|
}
|
|
596
826
|
|
|
597
|
-
// Attribute columns
|
|
598
|
-
|
|
827
|
+
// Attribute columns. Type conversion is NOT applied here -- it is applied
|
|
828
|
+
// once to the finished payload by the caller, via convertRow (#292).
|
|
829
|
+
for (const col of Object.keys(schema.columns)) {
|
|
599
830
|
if (data[col] !== undefined) {
|
|
600
|
-
|
|
601
|
-
row[col] = pgType === 'JSONB' && typeof data[col] !== 'string'
|
|
602
|
-
? JSON.stringify(data[col])
|
|
603
|
-
: data[col];
|
|
831
|
+
row[col] = data[col];
|
|
604
832
|
}
|
|
605
833
|
}
|
|
606
834
|
|
|
@@ -37,6 +37,27 @@ export function buildInsert(table: string, data: Record<string, unknown>): Query
|
|
|
37
37
|
return { sql, values };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
export function buildInsertMany(table: string, rows: Record<string, unknown>[]): QueryResult {
|
|
41
|
+
validateIdentifier(table, 'table name');
|
|
42
|
+
|
|
43
|
+
const columns = [...new Set(rows.flatMap(row => Object.keys(row)))];
|
|
44
|
+
columns.forEach(column => validateIdentifier(column, 'column name'));
|
|
45
|
+
|
|
46
|
+
const values: unknown[] = [];
|
|
47
|
+
const tuples = rows.map(row => {
|
|
48
|
+
const cells = columns.map(column => {
|
|
49
|
+
if (!Object.hasOwn(row, column)) return 'DEFAULT';
|
|
50
|
+
values.push(row[column]);
|
|
51
|
+
return `$${values.length}`;
|
|
52
|
+
});
|
|
53
|
+
return `(${cells.join(', ')})`;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const sql = `INSERT INTO "${table}" (${columns.map(column => `"${column}"`).join(', ')}) VALUES ${tuples.join(', ')} RETURNING "id"`;
|
|
57
|
+
|
|
58
|
+
return { sql, values };
|
|
59
|
+
}
|
|
60
|
+
|
|
40
61
|
export function buildUpdate(table: string, id: unknown, data: Record<string, unknown>): QueryResult {
|
|
41
62
|
validateIdentifier(table, 'table name');
|
|
42
63
|
|
package/src/record.ts
CHANGED
|
@@ -87,12 +87,15 @@ export default class Record {
|
|
|
87
87
|
|
|
88
88
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
89
89
|
if (Array.isArray(childRecord)) {
|
|
90
|
+
// Filter out cleaned records (those with no __model)
|
|
91
|
+
const live = childRecord.filter((r: Record) => r?.__model);
|
|
92
|
+
|
|
90
93
|
// Deduplicate by record ID — keep last occurrence (latest data wins)
|
|
91
94
|
const seen = new Set<unknown>();
|
|
92
95
|
const unique: Record[] = [];
|
|
93
96
|
|
|
94
|
-
for (let i =
|
|
95
|
-
const r =
|
|
97
|
+
for (let i = live.length - 1; i >= 0; i--) {
|
|
98
|
+
const r = live[i] as Record;
|
|
96
99
|
if (!seen.has(r.id)) {
|
|
97
100
|
seen.add(r.id);
|
|
98
101
|
unique.push(r);
|
|
@@ -102,7 +105,7 @@ export default class Record {
|
|
|
102
105
|
unique.reverse();
|
|
103
106
|
records[key] = unique.map((r: Record) => r.serialize());
|
|
104
107
|
} else {
|
|
105
|
-
records[key] = (childRecord as Record)?.serialize()
|
|
108
|
+
records[key] = (childRecord as Record)?.__model ? (childRecord as Record).serialize() : null;
|
|
106
109
|
}
|
|
107
110
|
}
|
|
108
111
|
|
|
@@ -136,8 +139,8 @@ export default class Record {
|
|
|
136
139
|
if (fields && !fields.has(key)) continue;
|
|
137
140
|
|
|
138
141
|
const relationshipData = Array.isArray(childRecord)
|
|
139
|
-
? childRecord.map((r: Record) => ({ type: r.__model.__name, id: r.id }))
|
|
140
|
-
: childRecord ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
|
|
142
|
+
? childRecord.filter((r: Record) => r?.__model).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
|
|
143
|
+
: (childRecord && (childRecord as Record).__model) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
|
|
141
144
|
|
|
142
145
|
// Dasherize the key for URL paths (e.g., accessLinks -> access-links)
|
|
143
146
|
const dasherizedKey = camelCaseToKebabCase(key);
|
package/src/relationships.ts
CHANGED
|
@@ -51,4 +51,4 @@ export function getPendingBelongsToRegistry(): PendingBelongsToMap {
|
|
|
51
51
|
return relationships.get('pendingBelongsTo') as PendingBelongsToMap;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export const TYPES: string[] = ['global', 'hasMany', 'belongsTo', 'pending'];
|
|
54
|
+
export const TYPES: string[] = ['global', 'hasMany', 'belongsTo', 'pending', 'pendingBelongsTo'];
|
package/src/serializer.ts
CHANGED
|
@@ -94,8 +94,45 @@ export default class Serializer {
|
|
|
94
94
|
const handlerOptions = { ...options, _relationshipKey: key };
|
|
95
95
|
const childRecord = handler(record, data, handlerOptions);
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
// hasMany relationships use a getter so format()/toJSON() always read
|
|
98
|
+
// the live registry array instead of a stale snapshot captured at
|
|
99
|
+
// serialization time. This is critical when child records are created
|
|
100
|
+
// in a later async frame — the belongsTo inverse wiring pushes into
|
|
101
|
+
// the shared registry array, and the getter ensures the parent sees it.
|
|
102
|
+
const isHasMany = (handler as { __relationshipType?: string }).__relationshipType === 'hasMany';
|
|
103
|
+
|
|
104
|
+
if (isHasMany) {
|
|
105
|
+
// `childRecord` IS the shared registry array — define a getter that
|
|
106
|
+
// always dereferences through the same array reference.
|
|
107
|
+
const registryArray = childRecord as unknown[];
|
|
108
|
+
Object.defineProperty(rec, key, {
|
|
109
|
+
enumerable: true,
|
|
110
|
+
configurable: true,
|
|
111
|
+
get: () => registryArray,
|
|
112
|
+
set(v: unknown) { relatedRecords[key] = v; }
|
|
113
|
+
});
|
|
114
|
+
Object.defineProperty(relatedRecords, key, {
|
|
115
|
+
enumerable: true,
|
|
116
|
+
configurable: true,
|
|
117
|
+
get: () => registryArray,
|
|
118
|
+
set(v: unknown) { Object.defineProperty(relatedRecords, key, { value: v, writable: true, enumerable: true, configurable: true }); }
|
|
119
|
+
});
|
|
120
|
+
} else {
|
|
121
|
+
rec[key] = childRecord;
|
|
122
|
+
relatedRecords[key] = childRecord;
|
|
123
|
+
|
|
124
|
+
// Preserve the raw FK value in __data when the belongsTo handler
|
|
125
|
+
// couldn't resolve the target (e.g., memory:false model not loaded).
|
|
126
|
+
// This allows adapters to read the FK from __data as a fallback
|
|
127
|
+
// when __relationships[key] is null. Only store when `data` is a
|
|
128
|
+
// truthy non-object — i.e., a raw FK string/number that the handler
|
|
129
|
+
// attempted but failed to resolve. When `data` is null/undefined
|
|
130
|
+
// (optional empty relationship) we intentionally skip to preserve
|
|
131
|
+
// the existing behavior of not populating __data for empty FKs.
|
|
132
|
+
if (childRecord === null && data && typeof data !== 'object') {
|
|
133
|
+
parsedData[key] = data;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
99
136
|
|
|
100
137
|
continue;
|
|
101
138
|
}
|