@stonyx/orm 0.3.2-beta.2 → 0.3.2-beta.200
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 +72 -2
- package/config/environment.js +8 -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 +1 -0
- package/dist/index.js +7 -2
- package/dist/main.d.ts +16 -0
- package/dist/main.js +36 -0
- package/dist/manage-record.d.ts +1 -0
- package/dist/manage-record.js +66 -4
- package/dist/mysql/connection.d.ts +1 -0
- package/dist/mysql/mysql-db.d.ts +9 -0
- package/dist/mysql/mysql-db.js +48 -12
- package/dist/orm-request.d.ts +1 -0
- package/dist/orm-request.js +32 -14
- package/dist/postgres/connection.d.ts +1 -0
- package/dist/postgres/connection.js +8 -6
- package/dist/postgres/postgres-db.d.ts +9 -0
- package/dist/postgres/postgres-db.js +64 -18
- 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 +74 -4
- package/dist/types/orm-types.d.ts +11 -0
- package/package.json +18 -7
- 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 +8 -2
- package/src/main.ts +45 -0
- package/src/manage-record.ts +72 -4
- package/src/mysql/connection.ts +1 -0
- package/src/mysql/mysql-db.ts +49 -14
- package/src/orm-request.ts +39 -13
- package/src/postgres/connection.ts +10 -6
- package/src/postgres/postgres-db.ts +63 -20
- package/src/record.ts +8 -5
- package/src/relationships.ts +1 -1
- package/src/serializer.ts +39 -2
- package/src/store.ts +78 -4
- package/src/types/orm-types.ts +12 -0
- package/src/types/stonyx.d.ts +7 -1
package/src/orm-request.ts
CHANGED
|
@@ -10,6 +10,10 @@ import { isOrmRecord } from './utils.js';
|
|
|
10
10
|
|
|
11
11
|
interface OrmRequest$ extends Request {
|
|
12
12
|
protocol?: string;
|
|
13
|
+
// Express sets this to the path the router was mounted at, e.g. '/api/animals'
|
|
14
|
+
// when orm.restServer.route is '/api'. Optional because non-Express callers
|
|
15
|
+
// (unit tests, programmatic handler invocation) do not supply it.
|
|
16
|
+
baseUrl?: string;
|
|
13
17
|
method: string;
|
|
14
18
|
params: { [key: string]: string };
|
|
15
19
|
body?: { [key: string]: unknown };
|
|
@@ -77,11 +81,30 @@ function getModelRelationships(modelName: string): { [key: string]: Relationship
|
|
|
77
81
|
return relationships;
|
|
78
82
|
}
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Build the absolute base URL that every advertised link hangs off — origin
|
|
86
|
+
* plus the prefix the ORM's routes are actually mounted at.
|
|
87
|
+
*
|
|
88
|
+
* The prefix is derived from the *request*, not from
|
|
89
|
+
* `config.orm.restServer.route`. Express sets `request.baseUrl` to the real
|
|
90
|
+
* mountpath registered by `RestServer.mountRoute`, which for this module is
|
|
91
|
+
* always `<prefix>/<pluralizedModel>` (see setup-rest-server.ts). Stripping the
|
|
92
|
+
* trailing model segment therefore yields the prefix by construction, and the
|
|
93
|
+
* link builder cannot drift from the mount registrar the way a second,
|
|
94
|
+
* independent normalisation of `route` would.
|
|
95
|
+
*
|
|
96
|
+
* When `request.baseUrl` is absent or does not end in the model segment the
|
|
97
|
+
* prefix is empty, which reproduces the previous origin-only behaviour.
|
|
98
|
+
*/
|
|
99
|
+
function getBaseUrl(request: OrmRequest$, pluralizedModel: string): string {
|
|
82
100
|
const protocol = request.protocol || 'http';
|
|
83
101
|
const host = request.get('host');
|
|
84
|
-
|
|
102
|
+
|
|
103
|
+
const modelSegment = `/${pluralizedModel}`;
|
|
104
|
+
const mountPath = request.baseUrl ?? '';
|
|
105
|
+
const prefix = mountPath.endsWith(modelSegment) ? mountPath.slice(0, -modelSegment.length) : '';
|
|
106
|
+
|
|
107
|
+
return `${protocol}://${host}${prefix}`;
|
|
85
108
|
}
|
|
86
109
|
|
|
87
110
|
function getId(params: { id?: string; [key: string]: unknown }): string | number {
|
|
@@ -278,7 +301,7 @@ export default class OrmRequest extends Request {
|
|
|
278
301
|
if (accessFilter) recordsToReturn = recordsToReturn.filter(accessFilter as (record: OrmRecord) => boolean);
|
|
279
302
|
if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate as (record: OrmRecord) => boolean);
|
|
280
303
|
|
|
281
|
-
const baseUrl = getBaseUrl(request);
|
|
304
|
+
const baseUrl = getBaseUrl(request, pluralizedModel);
|
|
282
305
|
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl }));
|
|
283
306
|
|
|
284
307
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
@@ -294,7 +317,7 @@ export default class OrmRequest extends Request {
|
|
|
294
317
|
const fieldsMap = parseFields(request.query);
|
|
295
318
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
296
319
|
|
|
297
|
-
const baseUrl = getBaseUrl(request);
|
|
320
|
+
const baseUrl = getBaseUrl(request, pluralizedModel);
|
|
298
321
|
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl }), request.query?.include, record, {
|
|
299
322
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
300
323
|
baseUrl
|
|
@@ -330,7 +353,7 @@ export default class OrmRequest extends Request {
|
|
|
330
353
|
}
|
|
331
354
|
|
|
332
355
|
const recordAttributes = id !== undefined ? { id, ...sanitizedAttributes } : sanitizedAttributes;
|
|
333
|
-
const created = createRecord(model, recordAttributes as { [key: string]: unknown }, { serialize: false });
|
|
356
|
+
const created = createRecord(model, recordAttributes as { [key: string]: unknown }, { serialize: false, _skipAutoPersist: true });
|
|
334
357
|
const record = isOrmRecord(created) ? created : null;
|
|
335
358
|
if (!record) return 500;
|
|
336
359
|
|
|
@@ -368,7 +391,7 @@ export default class OrmRequest extends Request {
|
|
|
368
391
|
}
|
|
369
392
|
}
|
|
370
393
|
if (Object.keys(relUpdates).length > 0) {
|
|
371
|
-
updateRecord(record as never, relUpdates);
|
|
394
|
+
updateRecord(record as never, relUpdates, { _skipAutoPersist: true });
|
|
372
395
|
}
|
|
373
396
|
}
|
|
374
397
|
|
|
@@ -376,7 +399,7 @@ export default class OrmRequest extends Request {
|
|
|
376
399
|
};
|
|
377
400
|
|
|
378
401
|
const deleteHandler: HandlerFn = ({ params }) => {
|
|
379
|
-
store.remove(model, getId(params));
|
|
402
|
+
store.remove(model, getId(params), { _skipAutoPersist: true });
|
|
380
403
|
return 204;
|
|
381
404
|
};
|
|
382
405
|
|
|
@@ -443,7 +466,12 @@ export default class OrmRequest extends Request {
|
|
|
443
466
|
// Execute main handler
|
|
444
467
|
const response = await handler(request, state);
|
|
445
468
|
|
|
446
|
-
//
|
|
469
|
+
// Set context.record for update BEFORE persist so SQL drivers can read it
|
|
470
|
+
if (operation === 'update' && (response as JsonApiResponse)?.data) {
|
|
471
|
+
context.record = store.get(this.model, getId(request.params));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Persist to SQL database for all write operations (create/update/delete)
|
|
447
475
|
const sqlDb = Orm.instance.sqlDb;
|
|
448
476
|
if (sqlDb && WRITE_OPERATIONS.has(operation)) {
|
|
449
477
|
await sqlDb.persist(operation, this.model, context, response);
|
|
@@ -461,8 +489,6 @@ export default class OrmRequest extends Request {
|
|
|
461
489
|
const responseData = (response as { data: { id: string | number } }).data;
|
|
462
490
|
const recordId = isNaN(responseData.id as unknown as number) ? responseData.id : parseInt(responseData.id as string);
|
|
463
491
|
context.record = store.get(this.model, recordId);
|
|
464
|
-
} else if (operation === 'update' && (response as JsonApiResponse)?.data) {
|
|
465
|
-
context.record = store.get(this.model, getId(request.params));
|
|
466
492
|
} else if (operation === 'delete') {
|
|
467
493
|
// For delete, the record may no longer exist, but we have oldState
|
|
468
494
|
context.recordId = getId(request.params);
|
|
@@ -499,7 +525,7 @@ export default class OrmRequest extends Request {
|
|
|
499
525
|
if (!record) return 404;
|
|
500
526
|
|
|
501
527
|
const relatedData = record.__relationships[relationshipName];
|
|
502
|
-
const baseUrl = getBaseUrl(request);
|
|
528
|
+
const baseUrl = getBaseUrl(request, pluralizedModel);
|
|
503
529
|
|
|
504
530
|
let data: unknown;
|
|
505
531
|
if (info.isArray) {
|
|
@@ -523,7 +549,7 @@ export default class OrmRequest extends Request {
|
|
|
523
549
|
if (!record) return 404;
|
|
524
550
|
|
|
525
551
|
const relatedData = record.__relationships[relationshipName];
|
|
526
|
-
const baseUrl = getBaseUrl(request);
|
|
552
|
+
const baseUrl = getBaseUrl(request, pluralizedModel);
|
|
527
553
|
|
|
528
554
|
let data: unknown;
|
|
529
555
|
if (info.isArray) {
|
|
@@ -8,6 +8,7 @@ interface PgConfig {
|
|
|
8
8
|
password: string;
|
|
9
9
|
database: string;
|
|
10
10
|
connectionLimit: number;
|
|
11
|
+
[key: string]: unknown;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
let pool: PgPool | null = null;
|
|
@@ -20,15 +21,18 @@ export async function getPool(pgConfig: PgConfig, extensions: string[] = ['vecto
|
|
|
20
21
|
|
|
21
22
|
const { default: pg } = await import('pg');
|
|
22
23
|
|
|
24
|
+
const { host, port, user, password, database, connectionLimit, migrationsDir, migrationsTable, autoMigrate, ...poolOpts } = pgConfig;
|
|
25
|
+
|
|
23
26
|
pool = new pg.Pool({
|
|
24
|
-
host
|
|
25
|
-
port
|
|
26
|
-
user
|
|
27
|
-
password
|
|
28
|
-
database
|
|
29
|
-
max:
|
|
27
|
+
host,
|
|
28
|
+
port,
|
|
29
|
+
user,
|
|
30
|
+
password,
|
|
31
|
+
database,
|
|
32
|
+
max: connectionLimit,
|
|
30
33
|
idleTimeoutMillis: 30000,
|
|
31
34
|
connectionTimeoutMillis: 10000,
|
|
35
|
+
...poolOpts,
|
|
32
36
|
});
|
|
33
37
|
|
|
34
38
|
// Enable requested PostgreSQL extensions
|
|
@@ -19,6 +19,7 @@ interface PersistContext {
|
|
|
19
19
|
record?: OrmRecord;
|
|
20
20
|
recordId?: unknown;
|
|
21
21
|
oldState?: Record<string, unknown>;
|
|
22
|
+
rawData?: Record<string, unknown>;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
interface PersistResponse {
|
|
@@ -89,6 +90,15 @@ export default class PostgresDB {
|
|
|
89
90
|
pool!: Pool | null;
|
|
90
91
|
pgConfig!: Record<string, unknown>;
|
|
91
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Promise-chain mutex for write serialization (#156).
|
|
95
|
+
* All persist() calls chain through this single queue so concurrent
|
|
96
|
+
* fire-and-forget writes never produce parallel transactions
|
|
97
|
+
* on FK-linked rows (which cause deadlocks).
|
|
98
|
+
* Reads are NOT affected — only persist() serializes.
|
|
99
|
+
*/
|
|
100
|
+
private _writeQueue: Promise<void> = Promise.resolve();
|
|
101
|
+
|
|
92
102
|
constructor(deps: Partial<PostgresDeps> = {}) {
|
|
93
103
|
const Ctor = this.constructor as typeof PostgresDB;
|
|
94
104
|
if (Ctor.instance) return Ctor.instance;
|
|
@@ -124,7 +134,15 @@ export default class PostgresDB {
|
|
|
124
134
|
if (pending.length > 0) {
|
|
125
135
|
this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
|
|
126
136
|
|
|
127
|
-
|
|
137
|
+
let shouldApply: boolean;
|
|
138
|
+
if (this.pgConfig.autoMigrate === true) {
|
|
139
|
+
shouldApply = true;
|
|
140
|
+
} else if (this.pgConfig.autoMigrate === false) {
|
|
141
|
+
shouldApply = false;
|
|
142
|
+
this.deps.log.warn?.(`autoMigrate is false — skipping ${pending.length} pending migration(s).`);
|
|
143
|
+
} else {
|
|
144
|
+
shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
|
|
145
|
+
}
|
|
128
146
|
|
|
129
147
|
if (shouldApply) {
|
|
130
148
|
for (const filename of pending) {
|
|
@@ -145,9 +163,17 @@ export default class PostgresDB {
|
|
|
145
163
|
const modelCount = Object.keys(schemas).length;
|
|
146
164
|
|
|
147
165
|
if (modelCount > 0) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
166
|
+
let shouldGenerate: boolean;
|
|
167
|
+
if (this.pgConfig.autoMigrate === true) {
|
|
168
|
+
shouldGenerate = true;
|
|
169
|
+
} else if (this.pgConfig.autoMigrate === false) {
|
|
170
|
+
shouldGenerate = false;
|
|
171
|
+
this.deps.log.warn?.(`autoMigrate is false — skipping initial migration generation for ${modelCount} model(s).`);
|
|
172
|
+
} else {
|
|
173
|
+
shouldGenerate = await this.deps.confirm(
|
|
174
|
+
`No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
151
177
|
|
|
152
178
|
if (shouldGenerate) {
|
|
153
179
|
const { generateMigration } = await import('./migration-generator.js');
|
|
@@ -467,17 +493,24 @@ export default class PostgresDB {
|
|
|
467
493
|
const Orm = (await import('@stonyx/orm')).default;
|
|
468
494
|
if ((Orm.instance as { isView?: (name: string) => boolean })?.isView?.(modelName)) return;
|
|
469
495
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
496
|
+
const work = async () => {
|
|
497
|
+
switch (operation) {
|
|
498
|
+
case 'create':
|
|
499
|
+
return this._persistCreate(modelName, context, response);
|
|
500
|
+
case 'update':
|
|
501
|
+
return this._persistUpdate(modelName, context, response);
|
|
502
|
+
case 'delete':
|
|
503
|
+
return this._persistDelete(modelName, context);
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
// Chain through the write queue — .then(work, work) ensures the queue
|
|
508
|
+
// advances even when a previous persist rejects (#156).
|
|
509
|
+
this._writeQueue = this._writeQueue.then(work, work);
|
|
510
|
+
return this._writeQueue;
|
|
478
511
|
}
|
|
479
512
|
|
|
480
|
-
private async _persistCreate(modelName: string,
|
|
513
|
+
private async _persistCreate(modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
|
|
481
514
|
const schemas = this.deps.introspectModels();
|
|
482
515
|
const schema = schemas[modelName];
|
|
483
516
|
|
|
@@ -491,10 +524,12 @@ export default class PostgresDB {
|
|
|
491
524
|
|
|
492
525
|
if (!record) return;
|
|
493
526
|
|
|
494
|
-
const insertData = this._recordToRow(record, schema);
|
|
527
|
+
const insertData = this._recordToRow(record, schema, context.rawData);
|
|
495
528
|
|
|
496
|
-
// For auto-increment models, remove the pending ID
|
|
497
|
-
|
|
529
|
+
// For auto-increment models, remove the pending ID.
|
|
530
|
+
// Check context.rawData (not record.__data) because __pendingSqlId is not a model
|
|
531
|
+
// attribute and gets lost during serialization.
|
|
532
|
+
const isPendingId = context.rawData?.__pendingSqlId === true;
|
|
498
533
|
|
|
499
534
|
if (isPendingId) {
|
|
500
535
|
delete insertData.id;
|
|
@@ -549,7 +584,10 @@ export default class PostgresDB {
|
|
|
549
584
|
// Check FK changes too
|
|
550
585
|
for (const fkCol of Object.keys(schema.foreignKeys)) {
|
|
551
586
|
const relName = fkCol.replace(/_id$/, '');
|
|
552
|
-
const
|
|
587
|
+
const relValue = record.__relationships[relName];
|
|
588
|
+
const currentFkValue = (relValue && typeof relValue === 'object' && relValue !== null)
|
|
589
|
+
? (relValue as { id: unknown }).id ?? null
|
|
590
|
+
: relValue ?? record.__data[relName] ?? null;
|
|
553
591
|
const oldFkValue = oldState[relName] ?? null;
|
|
554
592
|
|
|
555
593
|
if (currentFkValue !== oldFkValue) {
|
|
@@ -579,7 +617,7 @@ export default class PostgresDB {
|
|
|
579
617
|
await this.requirePool().query(sql, values);
|
|
580
618
|
}
|
|
581
619
|
|
|
582
|
-
private _recordToRow(record: OrmRecord, schema: ModelSchema): Record<string, unknown> {
|
|
620
|
+
private _recordToRow(record: OrmRecord, schema: ModelSchema, rawData?: Record<string, unknown>): Record<string, unknown> {
|
|
583
621
|
const row: Record<string, unknown> = {};
|
|
584
622
|
const data = record.__data;
|
|
585
623
|
|
|
@@ -603,11 +641,16 @@ export default class PostgresDB {
|
|
|
603
641
|
const relName = fkCol.replace(/_id$/, '');
|
|
604
642
|
const related = record.__relationships[relName];
|
|
605
643
|
|
|
606
|
-
if (related) {
|
|
644
|
+
if (related && typeof related === 'object' && related !== null) {
|
|
607
645
|
row[fkCol] = (related as { id: unknown }).id;
|
|
646
|
+
} else if (related != null) {
|
|
647
|
+
// Raw FK value (e.g., string ID stored directly in __relationships)
|
|
648
|
+
row[fkCol] = related;
|
|
608
649
|
} else if (data[relName] !== undefined) {
|
|
609
|
-
// Raw FK value (e.g., from create payload)
|
|
610
650
|
row[fkCol] = data[relName];
|
|
651
|
+
} else if (rawData?.[relName] !== undefined) {
|
|
652
|
+
// Fallback to original create payload for unresolved belongsTo FKs
|
|
653
|
+
row[fkCol] = rawData[relName];
|
|
611
654
|
}
|
|
612
655
|
}
|
|
613
656
|
|
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
|
}
|
package/src/store.ts
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
|
|
|
5
5
|
interface UnloadOptions {
|
|
@@ -64,7 +64,7 @@ export default class Store {
|
|
|
64
64
|
get(key: string): Map<number | string, unknown> | undefined;
|
|
65
65
|
get(key: string, id: number | string): unknown;
|
|
66
66
|
get(key: string, id?: number | string): Map<number | string, unknown> | unknown | undefined {
|
|
67
|
-
if (
|
|
67
|
+
if (id === undefined) return this.data.get(key);
|
|
68
68
|
|
|
69
69
|
return this.data.get(key)?.get(id);
|
|
70
70
|
}
|
|
@@ -170,17 +170,68 @@ export default class Store {
|
|
|
170
170
|
this.data.set(key, value);
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
remove(key: string, id?: number | string): void {
|
|
173
|
+
remove(key: string, id?: number | string, options?: { _skipAutoPersist?: boolean }): void {
|
|
174
174
|
// Guard: read-only views cannot have records removed
|
|
175
175
|
if (Orm.instance?.isView?.(key)) {
|
|
176
176
|
throw new Error(`Cannot remove records from read-only view '${key}'`);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// Auto-persist delete to SQL (fire-and-forget) — skipped when the
|
|
180
|
+
// request path handles persist itself to avoid double-delete.
|
|
181
|
+
if (id && Orm.instance?.sqlDb && !options?._skipAutoPersist) {
|
|
182
|
+
Orm.instance.sqlDb.persist('delete', key, { recordId: id }, {}).catch((err: unknown) => {
|
|
183
|
+
Orm.instance.emitPersistError({
|
|
184
|
+
operation: 'delete',
|
|
185
|
+
modelName: key,
|
|
186
|
+
recordId: id,
|
|
187
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
179
192
|
if (id) return this.unloadRecord(key, id);
|
|
180
193
|
|
|
181
194
|
this.unloadAllRecords(key);
|
|
182
195
|
}
|
|
183
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Evict a record from the store with full relationship registry cleanup.
|
|
199
|
+
* The caller retains its reference to the returned record, which is the
|
|
200
|
+
* contract memory:false post-persist eviction relies on.
|
|
201
|
+
*
|
|
202
|
+
* @param registryId - The ID used when the record's relationships were
|
|
203
|
+
* registered. For SQL models with pending IDs, this is the original
|
|
204
|
+
* negative pending ID (before the adapter re-keyed to the real DB ID).
|
|
205
|
+
*/
|
|
206
|
+
evictRecord(modelName: string, id: unknown, registryId?: unknown): void {
|
|
207
|
+
const modelStore = this.data.get(modelName);
|
|
208
|
+
if (!modelStore) return;
|
|
209
|
+
|
|
210
|
+
if (typeof id !== 'string' && typeof id !== 'number') return;
|
|
211
|
+
const raw = modelStore.get(id);
|
|
212
|
+
if (!raw || !isStoreRecord(raw)) return;
|
|
213
|
+
|
|
214
|
+
const visited = new Set([`${modelName}:${id}`]);
|
|
215
|
+
|
|
216
|
+
// Remove from hasMany arrays and nullify belongsTo references using current ID
|
|
217
|
+
// (the adapter updates record.id, so value-based matches need the current ID)
|
|
218
|
+
this._removeFromHasManyArrays(modelName, id, visited);
|
|
219
|
+
this._nullifyBelongsToReferences(modelName, id, visited);
|
|
220
|
+
|
|
221
|
+
// Clean up relationship registry entries using the registry key
|
|
222
|
+
// (belongsTo/hasMany registries were keyed by the ID at registration time,
|
|
223
|
+
// which may differ from the current ID if SQL persist re-keyed the record)
|
|
224
|
+
const cleanupId = registryId ?? id;
|
|
225
|
+
this._cleanupRelationshipRegistries(modelName, cleanupId);
|
|
226
|
+
|
|
227
|
+
// If registryId differs from id, also clean with current id as safety net
|
|
228
|
+
if (registryId !== undefined && registryId !== id) {
|
|
229
|
+
this._cleanupRelationshipRegistries(modelName, id);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
modelStore.delete(id);
|
|
233
|
+
}
|
|
234
|
+
|
|
184
235
|
unloadRecord(model: string, id: unknown, options: UnloadOptions = {}): void {
|
|
185
236
|
const modelStore = this.data.get(model);
|
|
186
237
|
|
|
@@ -207,7 +258,6 @@ export default class Store {
|
|
|
207
258
|
this._removeFromHasManyArrays(modelName, recordId, visited);
|
|
208
259
|
this._nullifyBelongsToReferences(modelName, recordId, visited);
|
|
209
260
|
this._cleanupRelationshipRegistries(modelName, recordId);
|
|
210
|
-
recordToUnload.clean();
|
|
211
261
|
|
|
212
262
|
this.data.get(modelName)?.delete(recordId as string | number);
|
|
213
263
|
}
|
|
@@ -296,6 +346,30 @@ export default class Store {
|
|
|
296
346
|
|
|
297
347
|
const pendingMap = getPendingRegistry().get(modelName);
|
|
298
348
|
if (pendingMap) pendingMap.delete(recordId);
|
|
349
|
+
|
|
350
|
+
// Clean pendingBelongsTo entries in both directions
|
|
351
|
+
const pendingBelongsToMap = getPendingBelongsToRegistry();
|
|
352
|
+
if (pendingBelongsToMap) {
|
|
353
|
+
// Direction 1: evicted record was the TARGET others were waiting for
|
|
354
|
+
const targetEntries = pendingBelongsToMap.get(modelName);
|
|
355
|
+
if (targetEntries) targetEntries.delete(recordId);
|
|
356
|
+
|
|
357
|
+
// Direction 2: evicted record was the SOURCE with unresolved forward-references
|
|
358
|
+
for (const [, targetIdMap] of pendingBelongsToMap) {
|
|
359
|
+
for (const [targetId, entries] of targetIdMap) {
|
|
360
|
+
if (!Array.isArray(entries)) continue;
|
|
361
|
+
const filtered = entries.filter((e: unknown) => {
|
|
362
|
+
const entry = e as { sourceModelName?: string; relationshipId?: unknown };
|
|
363
|
+
return !(entry.sourceModelName === modelName && entry.relationshipId === recordId);
|
|
364
|
+
});
|
|
365
|
+
if (filtered.length === 0) {
|
|
366
|
+
targetIdMap.delete(targetId);
|
|
367
|
+
} else if (filtered.length < entries.length) {
|
|
368
|
+
targetIdMap.set(targetId, filtered);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
299
373
|
}
|
|
300
374
|
|
|
301
375
|
/**
|
package/src/types/orm-types.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface OrmMysqlConfig {
|
|
|
18
18
|
connectionLimit?: number;
|
|
19
19
|
migrationsDir?: string;
|
|
20
20
|
migrationsTable?: string;
|
|
21
|
+
autoMigrate?: boolean;
|
|
21
22
|
[key: string]: unknown;
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -30,6 +31,7 @@ export interface OrmPostgresConfig {
|
|
|
30
31
|
connectionLimit?: number;
|
|
31
32
|
migrationsDir?: string;
|
|
32
33
|
migrationsTable?: string;
|
|
34
|
+
autoMigrate?: boolean;
|
|
33
35
|
[key: string]: unknown;
|
|
34
36
|
}
|
|
35
37
|
|
|
@@ -48,6 +50,13 @@ export interface OrmRestServerConfig {
|
|
|
48
50
|
metaRoute: boolean;
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
export interface OrmDynamoDBConfig {
|
|
54
|
+
region?: string;
|
|
55
|
+
endpoint?: string;
|
|
56
|
+
tablePrefix?: string;
|
|
57
|
+
[key: string]: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
51
60
|
export interface OrmSection {
|
|
52
61
|
db: OrmDbConfig;
|
|
53
62
|
paths: OrmPaths;
|
|
@@ -55,6 +64,9 @@ export interface OrmSection {
|
|
|
55
64
|
mysql?: OrmMysqlConfig;
|
|
56
65
|
postgres?: OrmPostgresConfig;
|
|
57
66
|
timescale?: OrmPostgresConfig;
|
|
67
|
+
dynamodb?: OrmDynamoDBConfig;
|
|
68
|
+
logColor?: string;
|
|
69
|
+
logMethod?: string;
|
|
58
70
|
[key: string]: unknown;
|
|
59
71
|
}
|
|
60
72
|
|
package/src/types/stonyx.d.ts
CHANGED
|
@@ -5,7 +5,13 @@ declare module 'stonyx/config' {
|
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
declare module 'stonyx/log' {
|
|
8
|
-
|
|
8
|
+
interface Log {
|
|
9
|
+
db(message: string): void;
|
|
10
|
+
error(message: string, ...args: unknown[]): void;
|
|
11
|
+
defineType(type: string, setting: string, options?: Record<string, unknown> | null): void;
|
|
12
|
+
[key: string]: ((...args: unknown[]) => void) | undefined;
|
|
13
|
+
}
|
|
14
|
+
const log: Log;
|
|
9
15
|
export default log;
|
|
10
16
|
}
|
|
11
17
|
|