@stonyx/orm 0.3.2-alpha.12 → 0.3.2-alpha.121
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 +341 -29
- package/dist/column-conversion.d.ts +34 -0
- package/dist/column-conversion.js +34 -0
- package/dist/dynamodb/connection.d.ts +7 -4
- package/dist/dynamodb/connection.js +4 -4
- package/dist/dynamodb/dynamodb-db.d.ts +11 -0
- package/dist/dynamodb/dynamodb-db.js +54 -14
- package/dist/dynamodb/operation-builder.js +20 -13
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/main.js +19 -1
- package/dist/manage-record.js +34 -3
- package/dist/mysql/connection.d.ts +1 -0
- package/dist/mysql/mysql-db.d.ts +8 -0
- package/dist/mysql/mysql-db.js +44 -10
- 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 +12 -0
- package/dist/postgres/postgres-db.js +60 -19
- 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/types/orm-types.d.ts +3 -0
- package/package.json +11 -7
- package/src/column-conversion.ts +53 -0
- package/src/dynamodb/connection.ts +9 -8
- package/src/dynamodb/dynamodb-db.ts +56 -13
- package/src/dynamodb/operation-builder.ts +29 -15
- package/src/index.ts +2 -0
- package/src/main.ts +19 -1
- package/src/manage-record.ts +41 -9
- package/src/mysql/connection.ts +1 -0
- package/src/mysql/mysql-db.ts +44 -12
- 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 +64 -21
- 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/types/orm-types.ts +3 -0
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
|
|
@@ -16,6 +16,7 @@ export interface OrmMysqlConfig {
|
|
|
16
16
|
connectionLimit?: number;
|
|
17
17
|
migrationsDir?: string;
|
|
18
18
|
migrationsTable?: string;
|
|
19
|
+
autoMigrate?: boolean;
|
|
19
20
|
[key: string]: unknown;
|
|
20
21
|
}
|
|
21
22
|
export interface OrmPostgresConfig {
|
|
@@ -27,6 +28,7 @@ export interface OrmPostgresConfig {
|
|
|
27
28
|
connectionLimit?: number;
|
|
28
29
|
migrationsDir?: string;
|
|
29
30
|
migrationsTable?: string;
|
|
31
|
+
autoMigrate?: boolean;
|
|
30
32
|
[key: string]: unknown;
|
|
31
33
|
}
|
|
32
34
|
export interface OrmPaths {
|
|
@@ -45,6 +47,7 @@ export interface OrmRestServerConfig {
|
|
|
45
47
|
export interface OrmDynamoDBConfig {
|
|
46
48
|
region?: string;
|
|
47
49
|
endpoint?: string;
|
|
50
|
+
tablePrefix?: string;
|
|
48
51
|
[key: string]: unknown;
|
|
49
52
|
}
|
|
50
53
|
export interface OrmSection {
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"stonyx-async",
|
|
5
5
|
"stonyx-module"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.3.2-alpha.
|
|
7
|
+
"version": "0.3.2-alpha.121",
|
|
8
8
|
"description": "",
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"type": "module",
|
|
@@ -61,9 +61,10 @@
|
|
|
61
61
|
},
|
|
62
62
|
"homepage": "https://github.com/abofs/stonyx-orm#readme",
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@stonyx/cron": "0.2.1-beta.
|
|
65
|
-
"@stonyx/events": "0.1.1-beta.
|
|
66
|
-
"stonyx": "0.2.3-beta.
|
|
64
|
+
"@stonyx/cron": "0.2.1-beta.149",
|
|
65
|
+
"@stonyx/events": "0.1.1-beta.68",
|
|
66
|
+
"@stonyx/utils": "0.2.3-beta.28",
|
|
67
|
+
"stonyx": "0.2.3-beta.101"
|
|
67
68
|
},
|
|
68
69
|
"peerDependencies": {
|
|
69
70
|
"@aws-sdk/client-dynamodb": "^3.0.0",
|
|
@@ -90,8 +91,7 @@
|
|
|
90
91
|
}
|
|
91
92
|
},
|
|
92
93
|
"devDependencies": {
|
|
93
|
-
"@stonyx/rest-server": "0.2.1-beta.
|
|
94
|
-
"@stonyx/utils": "0.2.3-beta.24",
|
|
94
|
+
"@stonyx/rest-server": "0.2.1-beta.151",
|
|
95
95
|
"@types/node": "^25.6.0",
|
|
96
96
|
"mysql2": "^3.20.0",
|
|
97
97
|
"pg": "^8.20.0",
|
|
@@ -103,6 +103,10 @@
|
|
|
103
103
|
"scripts": {
|
|
104
104
|
"build": "tsc",
|
|
105
105
|
"build:test": "tsc -p tsconfig.test.json",
|
|
106
|
-
"test": "pnpm build && NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts'"
|
|
106
|
+
"test": "pnpm build && NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts' && ORM_TEST_ROUTE=/ pnpm test:mounted && ORM_TEST_ROUTE=/api pnpm test:mounted && ORM_TEST_ROUTE=api pnpm test:mounted && ORM_TEST_ROUTE=/api/v1 pnpm test:mounted && ORM_TEST_ROUTE=/api/ pnpm test:mounted && pnpm test:readme && pnpm test:reference",
|
|
107
|
+
"test:mounted": "node --import tsx/esm --import ./test/integration/mounted-route/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/mounted-route/links-mounted.ts' 'test/zz-exit-test.ts'",
|
|
108
|
+
"test:readme": "pnpm build && node --import tsx/esm --import ./test/integration/readme-access/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/readme-access/readme-sample.ts' 'test/zz-exit-test.ts'",
|
|
109
|
+
"test:reference": "pnpm build && node --import tsx/esm --import ./test/integration/reference-access/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/reference-access/reference-sample.ts' 'test/integration/reference-access/record-id-resolution.ts' 'test/zz-exit-test.ts'",
|
|
110
|
+
"test:dynamodb": "pnpm build && node --import tsx/esm --import ./test/integration/dynamodb/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/dynamodb/**/*-test.ts'"
|
|
107
111
|
}
|
|
108
112
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared write-path column conversion (#292).
|
|
3
|
+
*
|
|
4
|
+
* This module owns the *iteration* only — the single place where a write
|
|
5
|
+
* payload is walked column-by-column and each value is handed to the driver's
|
|
6
|
+
* own conversion rule. The rules themselves are genuinely disjoint per driver
|
|
7
|
+
* (Postgres stringifies for JSONB; DynamoDB's DocumentClient marshals objects
|
|
8
|
+
* and arrays natively and must NOT be given stringified values) and therefore
|
|
9
|
+
* deliberately live with their drivers rather than being merged here.
|
|
10
|
+
*
|
|
11
|
+
* The duplicated create/update iteration was the drift vector that produced
|
|
12
|
+
* #292: `_recordToRow` converted, `_persistUpdate` did not. Routing both
|
|
13
|
+
* through `convertRow` removes the opportunity for the two to diverge again.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A driver-supplied per-column conversion rule.
|
|
18
|
+
*
|
|
19
|
+
* @param value the raw value about to be bound into the query
|
|
20
|
+
* @param columnType the column's declared type from `schema.columns`, or
|
|
21
|
+
* `undefined` when the column is not an attribute column
|
|
22
|
+
* (e.g. `updated_at`, FK columns and `id`, which are added
|
|
23
|
+
* outside the attribute loop). Rules MUST be a no-op for
|
|
24
|
+
* `undefined`.
|
|
25
|
+
*/
|
|
26
|
+
export type ColumnRule = (value: unknown, columnType: string | undefined) => unknown;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Applies `rule` to every column of a complete write payload.
|
|
30
|
+
*
|
|
31
|
+
* Call this on the *finished* payload immediately before handing it to the
|
|
32
|
+
* query builder — not inside a payload-building loop. Columns appended after
|
|
33
|
+
* such a loop (FKs, `updated_at`) would otherwise bypass conversion, which is
|
|
34
|
+
* the same class of defect #292 reports.
|
|
35
|
+
*/
|
|
36
|
+
export function convertRow(
|
|
37
|
+
row: Record<string, unknown>,
|
|
38
|
+
columns: Record<string, string>,
|
|
39
|
+
rule: ColumnRule
|
|
40
|
+
): Record<string, unknown> {
|
|
41
|
+
const converted: Record<string, unknown> = {};
|
|
42
|
+
|
|
43
|
+
for (const [col, value] of Object.entries(row)) {
|
|
44
|
+
// `Object.hasOwn`, not `columns[col]`: a column named `toString` or
|
|
45
|
+
// `constructor` would otherwise reach the rule with a function as its
|
|
46
|
+
// "declared type". Inert for the shipped Postgres rule (strict equality
|
|
47
|
+
// against 'JSONB'), but `ColumnRule` is the extension point other drivers
|
|
48
|
+
// write against, and the obvious `if (columnType)` rule would invert.
|
|
49
|
+
converted[col] = rule(value, Object.hasOwn(columns, col) ? columns[col] : undefined);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return converted;
|
|
53
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
export interface DynamoDBConfig {
|
|
9
9
|
region?: string;
|
|
10
10
|
endpoint?: string;
|
|
11
|
+
tablePrefix?: string;
|
|
11
12
|
[key: string]: unknown;
|
|
12
13
|
}
|
|
13
14
|
|
|
@@ -17,27 +18,27 @@ export type DocumentClient = {
|
|
|
17
18
|
send(command: unknown): Promise<unknown>;
|
|
18
19
|
};
|
|
19
20
|
|
|
20
|
-
export type
|
|
21
|
-
export type
|
|
21
|
+
export type DynamoDBClientConstructor = new (options: unknown) => { config: unknown };
|
|
22
|
+
export type DocumentClientFromFn = { from(client: unknown): DocumentClient };
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Create a DynamoDBDocumentClient from the given config.
|
|
25
26
|
* Uses dynamic import so @aws-sdk/* are optional peer deps.
|
|
26
27
|
*/
|
|
27
28
|
export async function createDocumentClient(dbConfig: DynamoDBConfig): Promise<DocumentClient> {
|
|
28
|
-
const {
|
|
29
|
-
|
|
29
|
+
const { DynamoDBClient } = await import('@aws-sdk/client-dynamodb' as string) as {
|
|
30
|
+
DynamoDBClient: DynamoDBClientConstructor;
|
|
30
31
|
};
|
|
31
|
-
const {
|
|
32
|
-
|
|
32
|
+
const { DynamoDBDocumentClient } = await import('@aws-sdk/lib-dynamodb' as string) as {
|
|
33
|
+
DynamoDBDocumentClient: DocumentClientFromFn;
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
const clientOptions: Record<string, unknown> = {};
|
|
36
37
|
if (dbConfig.region) clientOptions.region = dbConfig.region;
|
|
37
38
|
if (dbConfig.endpoint) clientOptions.endpoint = dbConfig.endpoint;
|
|
38
39
|
|
|
39
|
-
const rawClient = new
|
|
40
|
-
return
|
|
40
|
+
const rawClient = new DynamoDBClient(clientOptions);
|
|
41
|
+
return DynamoDBDocumentClient.from(rawClient);
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
/**
|
|
@@ -50,6 +50,24 @@ function generateUlid(): string {
|
|
|
50
50
|
return id;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Generates a monotonically unique numeric ID for DynamoDB tables with numeric keys.
|
|
55
|
+
* Uses timestamp-based generation with a sub-millisecond counter to ensure uniqueness.
|
|
56
|
+
*/
|
|
57
|
+
let _numericIdCounter = 0;
|
|
58
|
+
let _numericIdLastMs = 0;
|
|
59
|
+
|
|
60
|
+
function generateNumericId(): number {
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
if (now === _numericIdLastMs) {
|
|
63
|
+
_numericIdCounter++;
|
|
64
|
+
} else {
|
|
65
|
+
_numericIdLastMs = now;
|
|
66
|
+
_numericIdCounter = 0;
|
|
67
|
+
}
|
|
68
|
+
return now * 1000 + _numericIdCounter;
|
|
69
|
+
}
|
|
70
|
+
|
|
53
71
|
// ---------------------------------------------------------------------------
|
|
54
72
|
// SDK Command factories (injectable for testing without real AWS SDK)
|
|
55
73
|
// ---------------------------------------------------------------------------
|
|
@@ -199,6 +217,10 @@ export default class DynamoDBDB {
|
|
|
199
217
|
return this.client;
|
|
200
218
|
}
|
|
201
219
|
|
|
220
|
+
private _resolveTableName(modelName: string): string {
|
|
221
|
+
return (this.dbConfig.tablePrefix ?? '') + sanitizeTableName(this.deps.getPluralName(modelName));
|
|
222
|
+
}
|
|
223
|
+
|
|
202
224
|
/** Resolve Orm singleton — falls back to real import in production. */
|
|
203
225
|
private async _getOrm(): Promise<OrmModule> {
|
|
204
226
|
if (this.deps._importOrm) return this.deps._importOrm();
|
|
@@ -235,7 +257,7 @@ export default class DynamoDBDB {
|
|
|
235
257
|
const rawClient = new DynamoDBClient(clientOptions);
|
|
236
258
|
|
|
237
259
|
for (const [modelName, schema] of Object.entries(schemas)) {
|
|
238
|
-
const tableName =
|
|
260
|
+
const tableName = this._resolveTableName(modelName);
|
|
239
261
|
const gsis = this._buildGsiDefinitions(modelName, schema);
|
|
240
262
|
|
|
241
263
|
try {
|
|
@@ -297,6 +319,16 @@ export default class DynamoDBDB {
|
|
|
297
319
|
// SqlDb contract — persist
|
|
298
320
|
// -------------------------------------------------------------------------
|
|
299
321
|
|
|
322
|
+
/**
|
|
323
|
+
* DynamoDB does NOT use write serialization (#156).
|
|
324
|
+
*
|
|
325
|
+
* Unlike MySQL/PostgreSQL, DynamoDB has no server-side foreign key
|
|
326
|
+
* constraints and no multi-row transactions in standard single-item
|
|
327
|
+
* operations (PutItem, UpdateItem, DeleteItem). Each operation is
|
|
328
|
+
* atomic at the item level and cannot deadlock against other items.
|
|
329
|
+
* Concurrent fire-and-forget writes therefore cannot produce the
|
|
330
|
+
* cross-row lock contention that causes InnoDB/PG deadlocks.
|
|
331
|
+
*/
|
|
300
332
|
async persist(operation: string, modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
|
|
301
333
|
const OrmModule = await this._getOrm();
|
|
302
334
|
if (OrmModule.default?.instance?.isView?.(modelName)) return;
|
|
@@ -320,7 +352,7 @@ export default class DynamoDBDB {
|
|
|
320
352
|
const schema = schemas[modelName];
|
|
321
353
|
if (!schema) return undefined;
|
|
322
354
|
|
|
323
|
-
const tableName =
|
|
355
|
+
const tableName = this._resolveTableName(modelName);
|
|
324
356
|
const { GetCommand } = await this.deps.loadDocClientCommands();
|
|
325
357
|
|
|
326
358
|
const params = this.deps.buildGetItem(tableName, { id });
|
|
@@ -354,7 +386,7 @@ export default class DynamoDBDB {
|
|
|
354
386
|
const schema = schemas[modelName];
|
|
355
387
|
if (!schema) return [];
|
|
356
388
|
|
|
357
|
-
const tableName =
|
|
389
|
+
const tableName = this._resolveTableName(modelName);
|
|
358
390
|
|
|
359
391
|
try {
|
|
360
392
|
let items: Record<string, unknown>[];
|
|
@@ -421,7 +453,7 @@ export default class DynamoDBDB {
|
|
|
421
453
|
}
|
|
422
454
|
|
|
423
455
|
const schema = schemas[modelName];
|
|
424
|
-
const tableName =
|
|
456
|
+
const tableName = this._resolveTableName(modelName);
|
|
425
457
|
|
|
426
458
|
try {
|
|
427
459
|
const items = await this._paginatedScan(tableName);
|
|
@@ -457,12 +489,13 @@ export default class DynamoDBDB {
|
|
|
457
489
|
if (!record) return;
|
|
458
490
|
|
|
459
491
|
const isPendingId = context.rawData?.__pendingSqlId === true;
|
|
460
|
-
const tableName =
|
|
492
|
+
const tableName = this._resolveTableName(modelName);
|
|
461
493
|
|
|
462
|
-
// For
|
|
494
|
+
// For models with a pending ID, generate a unique replacement ID
|
|
463
495
|
let finalId: unknown = record.id;
|
|
464
496
|
if (isPendingId) {
|
|
465
|
-
|
|
497
|
+
const keyType = this.deps.getDynamoKeyType(schema.idType);
|
|
498
|
+
finalId = keyType === 'N' ? generateNumericId() : generateUlid();
|
|
466
499
|
}
|
|
467
500
|
|
|
468
501
|
const item = this._recordToItem(record, schema, context.rawData);
|
|
@@ -494,7 +527,7 @@ export default class DynamoDBDB {
|
|
|
494
527
|
const record = context.record;
|
|
495
528
|
if (!record) return;
|
|
496
529
|
|
|
497
|
-
const tableName =
|
|
530
|
+
const tableName = this._resolveTableName(modelName);
|
|
498
531
|
const id = record.id;
|
|
499
532
|
const oldState = context.oldState || {};
|
|
500
533
|
const currentData = record.__data;
|
|
@@ -504,7 +537,11 @@ export default class DynamoDBDB {
|
|
|
504
537
|
|
|
505
538
|
for (const col of Object.keys(schema.columns)) {
|
|
506
539
|
if (currentData[col] !== oldState[col]) {
|
|
507
|
-
|
|
540
|
+
const value = currentData[col] ?? null;
|
|
541
|
+
// Date objects must be serialized to ISO-8601 strings for DynamoDB 'S' storage
|
|
542
|
+
changedData[col] = (value instanceof Date)
|
|
543
|
+
? value.toISOString()
|
|
544
|
+
: value;
|
|
508
545
|
}
|
|
509
546
|
}
|
|
510
547
|
|
|
@@ -535,7 +572,7 @@ export default class DynamoDBDB {
|
|
|
535
572
|
const id = context.recordId;
|
|
536
573
|
if (id == null) return;
|
|
537
574
|
|
|
538
|
-
const tableName =
|
|
575
|
+
const tableName = this._resolveTableName(modelName);
|
|
539
576
|
const { DeleteCommand } = await this.deps.loadDocClientCommands();
|
|
540
577
|
const params = this.deps.buildDeleteItem(tableName, { id });
|
|
541
578
|
await this.requireClient().send(new DeleteCommand(params));
|
|
@@ -597,7 +634,7 @@ export default class DynamoDBDB {
|
|
|
597
634
|
const schemas = this.deps.introspectModels();
|
|
598
635
|
|
|
599
636
|
for (const [modelName, schema] of Object.entries(schemas)) {
|
|
600
|
-
const tableName =
|
|
637
|
+
const tableName = this._resolveTableName(modelName);
|
|
601
638
|
const modelGsis = new Map<string, string>();
|
|
602
639
|
|
|
603
640
|
for (const fkCol of Object.keys(schema.foreignKeys)) {
|
|
@@ -659,7 +696,7 @@ export default class DynamoDBDB {
|
|
|
659
696
|
}
|
|
660
697
|
|
|
661
698
|
private _buildGsiDefinitions(modelName: string, schema: ModelSchema): unknown[] {
|
|
662
|
-
const tableName =
|
|
699
|
+
const tableName = this._resolveTableName(modelName);
|
|
663
700
|
const gsis: unknown[] = [];
|
|
664
701
|
|
|
665
702
|
for (const fkCol of Object.keys(schema.foreignKeys)) {
|
|
@@ -720,7 +757,13 @@ export default class DynamoDBDB {
|
|
|
720
757
|
if (data.id !== undefined) item.id = data.id;
|
|
721
758
|
|
|
722
759
|
for (const col of Object.keys(schema.columns)) {
|
|
723
|
-
if (data[col] !== undefined)
|
|
760
|
+
if (data[col] !== undefined) {
|
|
761
|
+
const value = data[col];
|
|
762
|
+
// Date objects must be serialized to ISO-8601 strings for DynamoDB 'S' storage
|
|
763
|
+
item[col] = (value instanceof Date)
|
|
764
|
+
? value.toISOString()
|
|
765
|
+
: value;
|
|
766
|
+
}
|
|
724
767
|
}
|
|
725
768
|
|
|
726
769
|
for (const fkCol of Object.keys(schema.foreignKeys)) {
|
|
@@ -131,21 +131,27 @@ export function buildScan(
|
|
|
131
131
|
if (exclusiveStartKey) params.ExclusiveStartKey = exclusiveStartKey;
|
|
132
132
|
|
|
133
133
|
if (conditions && Object.keys(conditions).length > 0) {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
134
|
+
const validEntries = Object.entries(conditions).filter(
|
|
135
|
+
([, val]) => val !== undefined && val !== null,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (validEntries.length > 0) {
|
|
139
|
+
const names: Record<string, string> = {};
|
|
140
|
+
const values: Record<string, unknown> = {};
|
|
141
|
+
const clauses: string[] = [];
|
|
142
|
+
|
|
143
|
+
for (const [attr, val] of validEntries) {
|
|
144
|
+
const nameAlias = `#${attr}`;
|
|
145
|
+
const valAlias = `:${attr}`;
|
|
146
|
+
names[nameAlias] = attr;
|
|
147
|
+
values[valAlias] = val;
|
|
148
|
+
clauses.push(`${nameAlias} = ${valAlias}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
params.FilterExpression = clauses.join(' AND ');
|
|
152
|
+
params.ExpressionAttributeNames = names;
|
|
153
|
+
params.ExpressionAttributeValues = values;
|
|
144
154
|
}
|
|
145
|
-
|
|
146
|
-
params.FilterExpression = clauses.join(' AND ');
|
|
147
|
-
params.ExpressionAttributeNames = names;
|
|
148
|
-
params.ExpressionAttributeValues = values;
|
|
149
155
|
}
|
|
150
156
|
|
|
151
157
|
return params;
|
|
@@ -162,11 +168,19 @@ export function buildQuery(
|
|
|
162
168
|
keyConditions: Record<string, unknown>,
|
|
163
169
|
exclusiveStartKey?: Record<string, unknown>,
|
|
164
170
|
): QueryParams {
|
|
171
|
+
const validEntries = Object.entries(keyConditions).filter(
|
|
172
|
+
([, val]) => val !== undefined && val !== null,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (validEntries.length === 0) {
|
|
176
|
+
throw new Error('buildQuery: all keyCondition values are undefined/null');
|
|
177
|
+
}
|
|
178
|
+
|
|
165
179
|
const names: Record<string, string> = {};
|
|
166
180
|
const values: Record<string, unknown> = {};
|
|
167
181
|
const clauses: string[] = [];
|
|
168
182
|
|
|
169
|
-
for (const [attr, val] of
|
|
183
|
+
for (const [attr, val] of validEntries) {
|
|
170
184
|
const nameAlias = `#${attr}`;
|
|
171
185
|
const valAlias = `:${attr}`;
|
|
172
186
|
names[nameAlias] = attr;
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,8 @@ export type { PersistErrorDetail } from './main.js';
|
|
|
30
30
|
export { Model, View, Serializer }; // base classes
|
|
31
31
|
export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
|
|
32
32
|
export { count, avg, sum, min, max }; // aggregate helpers
|
|
33
|
+
export { default as normalizeRecordId } from './normalize-record-id.js'; // canonical URL-id -> record-id resolution (#270)
|
|
34
|
+
export type { OrmRequest$ as OrmAccessRequest } from './orm-request.js'; // the request access() is handed, incl. recordId (#270)
|
|
33
35
|
export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
|
|
34
36
|
|
|
35
37
|
// Store API:
|
package/src/main.ts
CHANGED
|
@@ -20,7 +20,6 @@ import log from 'stonyx/log';
|
|
|
20
20
|
import { forEachFileImport } from '@stonyx/utils/file';
|
|
21
21
|
import { kebabCaseToPascalCase, pluralize } from '@stonyx/utils/string';
|
|
22
22
|
import { registerPluralName } from './plural-registry.js';
|
|
23
|
-
import setupRestServer from './setup-rest-server.js';
|
|
24
23
|
import baseTransforms from './transforms.js';
|
|
25
24
|
import Store from './store.js';
|
|
26
25
|
import Serializer from './serializer.js';
|
|
@@ -177,6 +176,25 @@ export default class Orm {
|
|
|
177
176
|
}
|
|
178
177
|
|
|
179
178
|
if (restServer.enabled === 'true') {
|
|
179
|
+
// MUST stay dynamic. setup-rest-server.js names the optional
|
|
180
|
+
// '@stonyx/rest-server' peer in its own static graph — directly, and
|
|
181
|
+
// through orm-request.ts / meta-request.ts, which import `Request` at
|
|
182
|
+
// module scope because they extend it (correctly: an `extends` base
|
|
183
|
+
// class cannot be awaited). Node links a module's entire static graph
|
|
184
|
+
// before evaluating any of it, so a static import here puts that
|
|
185
|
+
// specifier on the entry graph and `import('@stonyx/orm')` throws
|
|
186
|
+
// ERR_MODULE_NOT_FOUND for an ORM-only consumer that never installed the
|
|
187
|
+
// optional peer.
|
|
188
|
+
//
|
|
189
|
+
// NOT the same reason the SQL/DynamoDB drivers above are lazy: those
|
|
190
|
+
// modules carry no static peer specifier that survives to `dist/`
|
|
191
|
+
// (postgres-db.ts:15 and mysql-db.ts:17 are `import type`, erased by
|
|
192
|
+
// tsc), so the `await import()` there is not what isolates pg / mysql2 /
|
|
193
|
+
// @aws-sdk — that happens one layer down, in src/*/connection.ts (and
|
|
194
|
+
// src/dynamodb/dynamodb-db.ts).
|
|
195
|
+
// setup-rest-server.js is the only dist module whose laziness is
|
|
196
|
+
// load-bearing for peer resolution. (#280)
|
|
197
|
+
const { default: setupRestServer } = await import('./setup-rest-server.js');
|
|
180
198
|
promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
|
|
181
199
|
}
|
|
182
200
|
|
package/src/manage-record.ts
CHANGED
|
@@ -79,6 +79,28 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
|
|
|
79
79
|
pendingHasMany.splice(0);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// FK-based inverse hasMany wiring — when a child record is created with a
|
|
83
|
+
// foreign-key field (e.g. `owner: 'owner-1'` on an animal), find any parent
|
|
84
|
+
// whose hasMany registry targets this model and push the child into the
|
|
85
|
+
// parent's shared array. This covers edge cases where the child is created
|
|
86
|
+
// in a separate async frame without a belongsTo handler firing.
|
|
87
|
+
const hasManyReg = getHasManyRegistry();
|
|
88
|
+
if (hasManyReg) {
|
|
89
|
+
for (const [parentModelName, targetMap] of hasManyReg) {
|
|
90
|
+
const childArrayMap = targetMap.get(modelName);
|
|
91
|
+
if (!childArrayMap) continue;
|
|
92
|
+
|
|
93
|
+
// Check if rawData contains a FK field matching the parent model name
|
|
94
|
+
const fkValue = rawData[parentModelName];
|
|
95
|
+
if (fkValue === undefined || fkValue === null) continue;
|
|
96
|
+
|
|
97
|
+
const parentArray = childArrayMap.get(fkValue);
|
|
98
|
+
if (parentArray && !parentArray.includes(record)) {
|
|
99
|
+
parentArray.push(record);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
82
104
|
// Fulfill pending belongsTo relationships
|
|
83
105
|
const pendingBelongsToQueue = getPendingBelongsToRegistry();
|
|
84
106
|
const pendingBelongsToRaw = pendingBelongsToQueue.get(modelName)?.get(record.id);
|
|
@@ -86,7 +108,7 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
|
|
|
86
108
|
|
|
87
109
|
if (pendingBelongsTo) {
|
|
88
110
|
const belongsToReg = getBelongsToRegistry();
|
|
89
|
-
const
|
|
111
|
+
const pendingHasManyReg = getHasManyRegistry();
|
|
90
112
|
|
|
91
113
|
for (const { sourceRecord, sourceModelName, relationshipKey, relationshipId } of pendingBelongsTo) {
|
|
92
114
|
// Update the belongsTo relationship on the source record
|
|
@@ -103,7 +125,7 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
|
|
|
103
125
|
}
|
|
104
126
|
|
|
105
127
|
// Wire inverse hasMany if it exists
|
|
106
|
-
const inverseHasMany =
|
|
128
|
+
const inverseHasMany = pendingHasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
|
|
107
129
|
|
|
108
130
|
if (inverseHasMany && !inverseHasMany.includes(sourceRecord)) {
|
|
109
131
|
inverseHasMany.push(sourceRecord);
|
|
@@ -117,15 +139,25 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
|
|
|
117
139
|
// Auto-persist to SQL — skip for DB loads (isDbRecord) and relationship resolution (_relationshipKey)
|
|
118
140
|
const shouldPersist = orm?.sqlDb && !options.isDbRecord && !userOptions._relationshipKey && !options._skipAutoPersist;
|
|
119
141
|
if (shouldPersist) {
|
|
142
|
+
// Capture ID before persist — SQL adapters re-key pending IDs to real DB IDs,
|
|
143
|
+
// but relationship registries were keyed with this original ID
|
|
144
|
+
const registryId = record.id;
|
|
120
145
|
const response = { data: { id: record.id } };
|
|
121
|
-
orm!.sqlDb!.persist('create', modelName, { rawData }, response)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
146
|
+
orm!.sqlDb!.persist('create', modelName, { rawData }, response)
|
|
147
|
+
.catch((err: unknown) => {
|
|
148
|
+
orm!.emitPersistError({
|
|
149
|
+
operation: 'create',
|
|
150
|
+
modelName,
|
|
151
|
+
recordId: record.id,
|
|
152
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
153
|
+
});
|
|
154
|
+
})
|
|
155
|
+
.finally(() => {
|
|
156
|
+
// Evict non-memory records after persist to prevent unbounded heap growth (stonyx#81)
|
|
157
|
+
if (store._memoryResolver && !store._memoryResolver(modelName)) {
|
|
158
|
+
store.evictRecord(modelName, record.id, registryId);
|
|
159
|
+
}
|
|
127
160
|
});
|
|
128
|
-
});
|
|
129
161
|
}
|
|
130
162
|
|
|
131
163
|
return record;
|