@stonyx/orm 0.3.2-alpha.6 → 0.3.2-alpha.61

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.
Files changed (53) hide show
  1. package/README.md +580 -10
  2. package/config/{environment.ts → environment.js} +8 -0
  3. package/dist/commands.js +34 -0
  4. package/dist/dynamodb/connection.d.ts +31 -0
  5. package/dist/dynamodb/connection.js +28 -0
  6. package/dist/dynamodb/dynamodb-db.d.ts +142 -0
  7. package/dist/dynamodb/dynamodb-db.js +596 -0
  8. package/dist/dynamodb/operation-builder.d.ts +76 -0
  9. package/dist/dynamodb/operation-builder.js +116 -0
  10. package/dist/dynamodb/type-map.d.ts +31 -0
  11. package/dist/dynamodb/type-map.js +48 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/main.d.ts +116 -0
  14. package/dist/main.js +129 -0
  15. package/dist/manage-record.js +137 -11
  16. package/dist/mysql/connection.d.ts +1 -0
  17. package/dist/mysql/mysql-db.d.ts +8 -0
  18. package/dist/mysql/mysql-db.js +44 -10
  19. package/dist/orm-request.d.ts +181 -3
  20. package/dist/orm-request.js +794 -47
  21. package/dist/postgres/connection.d.ts +1 -0
  22. package/dist/postgres/connection.js +8 -6
  23. package/dist/postgres/postgres-db.d.ts +8 -0
  24. package/dist/postgres/postgres-db.js +44 -10
  25. package/dist/record.js +7 -5
  26. package/dist/relationships.js +1 -1
  27. package/dist/serializer.js +38 -2
  28. package/dist/setup-rest-server.js +51 -5
  29. package/dist/store.d.ts +13 -1
  30. package/dist/store.js +65 -6
  31. package/dist/types/orm-types.d.ts +112 -0
  32. package/package.json +16 -7
  33. package/src/commands.ts +43 -0
  34. package/src/dynamodb/connection.ts +50 -0
  35. package/src/dynamodb/dynamodb-db.ts +811 -0
  36. package/src/dynamodb/operation-builder.ts +202 -0
  37. package/src/dynamodb/type-map.ts +54 -0
  38. package/src/index.ts +1 -0
  39. package/src/main.ts +133 -0
  40. package/src/manage-record.ts +154 -17
  41. package/src/mysql/connection.ts +1 -0
  42. package/src/mysql/mysql-db.ts +44 -12
  43. package/src/orm-request.ts +809 -50
  44. package/src/postgres/connection.ts +10 -6
  45. package/src/postgres/postgres-db.ts +44 -12
  46. package/src/record.ts +8 -5
  47. package/src/relationships.ts +1 -1
  48. package/src/serializer.ts +39 -2
  49. package/src/setup-rest-server.ts +59 -6
  50. package/src/store.ts +68 -6
  51. package/src/types/orm-types.ts +118 -0
  52. package/src/types/stonyx-rest-server.d.ts +14 -1
  53. package/src/types/stonyx.d.ts +7 -1
@@ -6,6 +6,7 @@ interface PgConfig {
6
6
  password: string;
7
7
  database: string;
8
8
  connectionLimit: number;
9
+ [key: string]: unknown;
9
10
  }
10
11
  /**
11
12
  * Create or return the singleton pg Pool.
@@ -7,15 +7,17 @@ export async function getPool(pgConfig, extensions = ['vector']) {
7
7
  if (pool)
8
8
  return pool;
9
9
  const { default: pg } = await import('pg');
10
+ const { host, port, user, password, database, connectionLimit, migrationsDir, migrationsTable, autoMigrate, ...poolOpts } = pgConfig;
10
11
  pool = new pg.Pool({
11
- host: pgConfig.host,
12
- port: pgConfig.port,
13
- user: pgConfig.user,
14
- password: pgConfig.password,
15
- database: pgConfig.database,
16
- max: pgConfig.connectionLimit,
12
+ host,
13
+ port,
14
+ user,
15
+ password,
16
+ database,
17
+ max: connectionLimit,
17
18
  idleTimeoutMillis: 30000,
18
19
  connectionTimeoutMillis: 10000,
20
+ ...poolOpts,
19
21
  });
20
22
  // Enable requested PostgreSQL extensions
21
23
  for (const ext of extensions) {
@@ -72,6 +72,14 @@ export default class PostgresDB {
72
72
  deps: PostgresDeps;
73
73
  pool: Pool | null;
74
74
  pgConfig: Record<string, unknown>;
75
+ /**
76
+ * Promise-chain mutex for write serialization (#156).
77
+ * All persist() calls chain through this single queue so concurrent
78
+ * fire-and-forget writes never produce parallel transactions
79
+ * on FK-linked rows (which cause deadlocks).
80
+ * Reads are NOT affected — only persist() serializes.
81
+ */
82
+ private _writeQueue;
75
83
  constructor(deps?: Partial<PostgresDeps>);
76
84
  protected requirePool(): Pool;
77
85
  init(): Promise<void>;
@@ -30,6 +30,14 @@ export default class PostgresDB {
30
30
  deps;
31
31
  pool;
32
32
  pgConfig;
33
+ /**
34
+ * Promise-chain mutex for write serialization (#156).
35
+ * All persist() calls chain through this single queue so concurrent
36
+ * fire-and-forget writes never produce parallel transactions
37
+ * on FK-linked rows (which cause deadlocks).
38
+ * Reads are NOT affected — only persist() serializes.
39
+ */
40
+ _writeQueue = Promise.resolve();
33
41
  constructor(deps = {}) {
34
42
  const Ctor = this.constructor;
35
43
  if (Ctor.instance)
@@ -57,7 +65,17 @@ export default class PostgresDB {
57
65
  const pending = files.filter(f => !applied.includes(f));
58
66
  if (pending.length > 0) {
59
67
  this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
60
- const shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
68
+ let shouldApply;
69
+ if (this.pgConfig.autoMigrate === true) {
70
+ shouldApply = true;
71
+ }
72
+ else if (this.pgConfig.autoMigrate === false) {
73
+ shouldApply = false;
74
+ this.deps.log.warn?.(`autoMigrate is false — skipping ${pending.length} pending migration(s).`);
75
+ }
76
+ else {
77
+ shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
78
+ }
61
79
  if (shouldApply) {
62
80
  for (const filename of pending) {
63
81
  const content = await this.deps.readFile(this.deps.path.join(migrationsPath, filename));
@@ -76,7 +94,17 @@ export default class PostgresDB {
76
94
  const schemas = this.deps.introspectModels();
77
95
  const modelCount = Object.keys(schemas).length;
78
96
  if (modelCount > 0) {
79
- const shouldGenerate = await this.deps.confirm(`No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`);
97
+ let shouldGenerate;
98
+ if (this.pgConfig.autoMigrate === true) {
99
+ shouldGenerate = true;
100
+ }
101
+ else if (this.pgConfig.autoMigrate === false) {
102
+ shouldGenerate = false;
103
+ this.deps.log.warn?.(`autoMigrate is false — skipping initial migration generation for ${modelCount} model(s).`);
104
+ }
105
+ else {
106
+ shouldGenerate = await this.deps.confirm(`No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`);
107
+ }
80
108
  if (shouldGenerate) {
81
109
  const { generateMigration } = await import('./migration-generator.js');
82
110
  const result = await generateMigration('initial_setup');
@@ -356,14 +384,20 @@ export default class PostgresDB {
356
384
  const Orm = (await import('@stonyx/orm')).default;
357
385
  if (Orm.instance?.isView?.(modelName))
358
386
  return;
359
- switch (operation) {
360
- case 'create':
361
- return this._persistCreate(modelName, context, response);
362
- case 'update':
363
- return this._persistUpdate(modelName, context, response);
364
- case 'delete':
365
- return this._persistDelete(modelName, context);
366
- }
387
+ const work = async () => {
388
+ switch (operation) {
389
+ case 'create':
390
+ return this._persistCreate(modelName, context, response);
391
+ case 'update':
392
+ return this._persistUpdate(modelName, context, response);
393
+ case 'delete':
394
+ return this._persistDelete(modelName, context);
395
+ }
396
+ };
397
+ // Chain through the write queue — .then(work, work) ensures the queue
398
+ // advances even when a previous persist rejects (#156).
399
+ this._writeQueue = this._writeQueue.then(work, work);
400
+ return this._writeQueue;
367
401
  }
368
402
  async _persistCreate(modelName, context, response) {
369
403
  const schemas = this.deps.introspectModels();
package/dist/record.js CHANGED
@@ -40,11 +40,13 @@ export default class Record {
40
40
  const records = {};
41
41
  for (const [key, childRecord] of Object.entries(this.__relationships)) {
42
42
  if (Array.isArray(childRecord)) {
43
+ // Filter out cleaned records (those with no __model)
44
+ const live = childRecord.filter((r) => r?.__model);
43
45
  // Deduplicate by record ID — keep last occurrence (latest data wins)
44
46
  const seen = new Set();
45
47
  const unique = [];
46
- for (let i = childRecord.length - 1; i >= 0; i--) {
47
- const r = childRecord[i];
48
+ for (let i = live.length - 1; i >= 0; i--) {
49
+ const r = live[i];
48
50
  if (!seen.has(r.id)) {
49
51
  seen.add(r.id);
50
52
  unique.push(r);
@@ -54,7 +56,7 @@ export default class Record {
54
56
  records[key] = unique.map((r) => r.serialize());
55
57
  }
56
58
  else {
57
- records[key] = childRecord?.serialize() ?? null;
59
+ records[key] = childRecord?.__model ? childRecord.serialize() : null;
58
60
  }
59
61
  }
60
62
  return { ...data, ...records };
@@ -86,8 +88,8 @@ export default class Record {
86
88
  if (fields && !fields.has(key))
87
89
  continue;
88
90
  const relationshipData = Array.isArray(childRecord)
89
- ? childRecord.map((r) => ({ type: r.__model.__name, id: r.id }))
90
- : childRecord ? { type: childRecord.__model.__name, id: childRecord.id } : null;
91
+ ? childRecord.filter((r) => r?.__model).map((r) => ({ type: r.__model.__name, id: r.id }))
92
+ : (childRecord && childRecord.__model) ? { type: childRecord.__model.__name, id: childRecord.id } : null;
91
93
  // Dasherize the key for URL paths (e.g., accessLinks -> access-links)
92
94
  const dasherizedKey = camelCaseToKebabCase(key);
93
95
  relationships[dasherizedKey] = { data: relationshipData };
@@ -38,4 +38,4 @@ export function getPendingRegistry() {
38
38
  export function getPendingBelongsToRegistry() {
39
39
  return relationships.get('pendingBelongsTo');
40
40
  }
41
- export const TYPES = ['global', 'hasMany', 'belongsTo', 'pending'];
41
+ export const TYPES = ['global', 'hasMany', 'belongsTo', 'pending', 'pendingBelongsTo'];
@@ -79,8 +79,44 @@ export default class Serializer {
79
79
  // Pass relationship key name to handler for pending fulfillment
80
80
  const handlerOptions = { ...options, _relationshipKey: key };
81
81
  const childRecord = handler(record, data, handlerOptions);
82
- rec[key] = childRecord;
83
- relatedRecords[key] = childRecord;
82
+ // hasMany relationships use a getter so format()/toJSON() always read
83
+ // the live registry array instead of a stale snapshot captured at
84
+ // serialization time. This is critical when child records are created
85
+ // in a later async frame — the belongsTo inverse wiring pushes into
86
+ // the shared registry array, and the getter ensures the parent sees it.
87
+ const isHasMany = handler.__relationshipType === 'hasMany';
88
+ if (isHasMany) {
89
+ // `childRecord` IS the shared registry array — define a getter that
90
+ // always dereferences through the same array reference.
91
+ const registryArray = childRecord;
92
+ Object.defineProperty(rec, key, {
93
+ enumerable: true,
94
+ configurable: true,
95
+ get: () => registryArray,
96
+ set(v) { relatedRecords[key] = v; }
97
+ });
98
+ Object.defineProperty(relatedRecords, key, {
99
+ enumerable: true,
100
+ configurable: true,
101
+ get: () => registryArray,
102
+ set(v) { Object.defineProperty(relatedRecords, key, { value: v, writable: true, enumerable: true, configurable: true }); }
103
+ });
104
+ }
105
+ else {
106
+ rec[key] = childRecord;
107
+ relatedRecords[key] = childRecord;
108
+ // Preserve the raw FK value in __data when the belongsTo handler
109
+ // couldn't resolve the target (e.g., memory:false model not loaded).
110
+ // This allows adapters to read the FK from __data as a fallback
111
+ // when __relationships[key] is null. Only store when `data` is a
112
+ // truthy non-object — i.e., a raw FK string/number that the handler
113
+ // attempted but failed to resolve. When `data` is null/undefined
114
+ // (optional empty relationship) we intentionally skip to preserve
115
+ // the existing behavior of not populating __data for empty FKs.
116
+ if (childRecord === null && data && typeof data !== 'object') {
117
+ parsedData[key] = data;
118
+ }
119
+ }
84
120
  continue;
85
121
  }
86
122
  // Aggregate property handling — use the rawData value, not the aggregate descriptor
@@ -1,5 +1,5 @@
1
1
  import { waitForModule } from 'stonyx';
2
- import { store } from '@stonyx/orm';
2
+ import Orm, { store } from '@stonyx/orm';
3
3
  import OrmRequest from './orm-request.js';
4
4
  import MetaRequest from './meta-request.js';
5
5
  import RestServer from '@stonyx/rest-server';
@@ -8,7 +8,7 @@ import { dbKey } from './db.js';
8
8
  import { getPluralName } from './plural-registry.js';
9
9
  import log from 'stonyx/log';
10
10
  export default async function (route, accessPath, metaRoute) {
11
- const accessFiles = {};
11
+ const accessFunctions = {};
12
12
  try {
13
13
  await forEachFileImport(accessPath, (accessClass) => {
14
14
  const accessInstance = new accessClass();
@@ -25,9 +25,9 @@ export default async function (route, accessPath, metaRoute) {
25
25
  continue;
26
26
  if (!store.data.has(model))
27
27
  throw new Error(`Unable to define access for Invalid Model "${model}". Model does not exist`);
28
- if (accessFiles[model])
28
+ if (accessFunctions[model])
29
29
  throw new Error(`Access for model "${model}" has already been defined by another access class.`);
30
- accessFiles[model] = accessInstance.access;
30
+ accessFunctions[model] = accessInstance.access;
31
31
  }
32
32
  });
33
33
  }
@@ -35,11 +35,57 @@ export default async function (route, accessPath, metaRoute) {
35
35
  log.error?.(error instanceof Error ? error.message : String(error));
36
36
  log.warn?.('You must define a valid access configuration file in order to access ORM generated REST endpoints.');
37
37
  }
38
+ // -------------------------------------------------------------------------
39
+ // #202 -- the registry has to survive this function.
40
+ //
41
+ // `accessFunctions` used to be a function-local that was discarded at the return
42
+ // below, so the only thing that ever saw it was the mount loop. Each mounted
43
+ // OrmRequest then held its OWN model's predicate and nothing held the map, so
44
+ // at request time there was no route from a model NAME to that model's
45
+ // predicate -- which is what abofs/stonyx-orm#196 and #207 need in order to
46
+ // ask model X's predicate about a request routed to model Y.
47
+ //
48
+ // Published BEFORE `await waitForModule('rest-server')`, deliberately: that
49
+ // await is the ONLY yield point in this function, and the rest-server module
50
+ // may already be listening by the time it reports ready, so an assignment
51
+ // after it would leave a window in which a route is live and the registry is
52
+ // not.
53
+ //
54
+ // It is NOT before the mount loop for that reason, and the comment here used
55
+ // to say it was. `RestServer.mountRoute` is fully synchronous -- construct,
56
+ // registerCalls(), api.use() -- and nothing between the loop and this
57
+ // function's closing brace yields, so the event loop cannot deliver a request
58
+ // in there and the window that clause described cannot open. Measured:
59
+ // moving this assignment to the last statement of the function leaves the
60
+ // suite at 951 pass / 0 fail. Being ahead of the mount loop is free and
61
+ // harmless; it is not what makes the ordering correct.
62
+ //
63
+ // Assigned unconditionally, including when the try above failed and the map
64
+ // is empty or partial: the mount loop below is driven by this exact object,
65
+ // so at the moment of assignment whatever is reachable through
66
+ // `Orm.instance` is the same set of predicates that is about to enforce.
67
+ // A guard such as `if (Object.keys(accessFunctions).length)` would let the
68
+ // registry go silently missing on a total load failure, and a later consumer
69
+ // would read `undefined` from `getAccess` and have to distinguish "no access
70
+ // class" from "the registry was never published" -- which it cannot. That is
71
+ // the reasoning, and it is REASONING, not something this suite tests: the
72
+ // guarded variant is also 951 pass / 0 fail, AC8 included, because every boot
73
+ // in this suite loads a non-empty access map so the guard never fires. AC8
74
+ // demonstrably cannot catch it. Catching it needs a boot with
75
+ // `orm.paths.access` pointed at an empty directory, which this suite has no
76
+ // harness for.
77
+ //
78
+ // One further limit on "by construction": the mount loop passes `access` BY
79
+ // VALUE into each OrmRequest, so the enforcing set is a snapshot taken here,
80
+ // while `getAccess` reads the map live. The two are the same set at boot and
81
+ // stay the same set only for as long as nobody writes to the public field.
82
+ // The equality is a boot-time fact, not an invariant.
83
+ Orm.instance.accessFunctions = accessFunctions;
38
84
  await waitForModule('rest-server');
39
85
  // Remove "/" prefix and name mount point accordingly
40
86
  const name = route === '/' ? 'index' : (route[0] === '/' ? route.slice(1) : route);
41
87
  // Configure endpoints for models and views with access configuration
42
- for (const [model, access] of Object.entries(accessFiles)) {
88
+ for (const [model, access] of Object.entries(accessFunctions)) {
43
89
  const pluralizedModel = getPluralName(model);
44
90
  const modelName = name === 'index' ? pluralizedModel : `${name}/${pluralizedModel}`;
45
91
  RestServer.instance.mountRoute(OrmRequest, { name: modelName, options: { model, access } });
package/dist/store.d.ts CHANGED
@@ -45,7 +45,19 @@ export default class Store {
45
45
  */
46
46
  private _isMemoryModel;
47
47
  set(key: string, value: Map<number | string, unknown>): void;
48
- remove(key: string, id?: number | string): void;
48
+ remove(key: string, id?: number | string, options?: {
49
+ _skipAutoPersist?: boolean;
50
+ }): void;
51
+ /**
52
+ * Evict a record from the store with full relationship registry cleanup.
53
+ * The caller retains its reference to the returned record, which is the
54
+ * contract memory:false post-persist eviction relies on.
55
+ *
56
+ * @param registryId - The ID used when the record's relationships were
57
+ * registered. For SQL models with pending IDs, this is the original
58
+ * negative pending ID (before the adapter re-keyed to the real DB ID).
59
+ */
60
+ evictRecord(modelName: string, id: unknown, registryId?: unknown): void;
49
61
  unloadRecord(model: string, id: unknown, options?: UnloadOptions): void;
50
62
  unloadAllRecords(model: string, options?: UnloadOptions): void;
51
63
  private _removeFromHasManyArrays;
package/dist/store.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import Orm, { relationships } from '@stonyx/orm';
2
- import { TYPES, getHasManyRegistry, getBelongsToRegistry, getPendingRegistry } from './relationships.js';
2
+ import { TYPES, getHasManyRegistry, getBelongsToRegistry, getPendingRegistry, getPendingBelongsToRegistry } from './relationships.js';
3
3
  import ViewResolver from './view-resolver.js';
4
4
  function isStoreRecord(value) {
5
5
  return typeof value === 'object' && value !== null && '__data' in value;
@@ -22,7 +22,7 @@ export default class Store {
22
22
  this.data = new Map();
23
23
  }
24
24
  get(key, id) {
25
- if (!id)
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
- if (id && Orm.instance?.sqlDb) {
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 {
@@ -42,6 +44,12 @@ export interface OrmRestServerConfig {
42
44
  route: string;
43
45
  metaRoute: boolean;
44
46
  }
47
+ export interface OrmDynamoDBConfig {
48
+ region?: string;
49
+ endpoint?: string;
50
+ tablePrefix?: string;
51
+ [key: string]: unknown;
52
+ }
45
53
  export interface OrmSection {
46
54
  db: OrmDbConfig;
47
55
  paths: OrmPaths;
@@ -49,6 +57,9 @@ export interface OrmSection {
49
57
  mysql?: OrmMysqlConfig;
50
58
  postgres?: OrmPostgresConfig;
51
59
  timescale?: OrmPostgresConfig;
60
+ dynamodb?: OrmDynamoDBConfig;
61
+ logColor?: string;
62
+ logMethod?: string;
52
63
  [key: string]: unknown;
53
64
  }
54
65
  export interface OrmConfig {
@@ -151,3 +162,104 @@ export interface SnapshotEntry {
151
162
  source?: string;
152
163
  viewQuery?: string;
153
164
  }
165
+ /**
166
+ * The shapes a consumer `access()` predicate may return.
167
+ *
168
+ * - `false` (or any falsy value) -- deny, 403.
169
+ * - `true` -- allow, with no per-record filter.
170
+ * - a permission string or array of them, drawn from the same four verbs as
171
+ * {@link AccessContext.operation}. A BARE STRING IS ONE PERMISSION, not a
172
+ * grant of all four.
173
+ * - a `(record) => boolean` predicate -- allow, and filter every record the
174
+ * request touches through it.
175
+ *
176
+ * Anything else fails CLOSED. See `src/orm-request.ts` `auth()`.
177
+ */
178
+ export type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
179
+ /**
180
+ * The closed vocabulary `AccessContext.operation` is drawn from
181
+ * (abofs/stonyx-orm#202).
182
+ *
183
+ * A literal union rather than `string`, so the guarantee the prose makes is the
184
+ * one the compiler enforces: a consumer who writes `operation === 'GET'` or
185
+ * `operation === 'get'` -- the hook vocabulary, see below -- gets a compile
186
+ * error instead of a comparison that never matches. A predicate that stops
187
+ * matching falls through to the permission array, so the misreading is
188
+ * fail-open shaped.
189
+ *
190
+ * In-repo precedent: `PersistErrorDetail.operation` in `src/main.ts`.
191
+ */
192
+ export type AccessOperation = 'read' | 'create' | 'update' | 'delete';
193
+ /**
194
+ * The structural facts about the request being authorised, handed to a consumer
195
+ * `access()` predicate as its SECOND argument (abofs/stonyx-orm#202).
196
+ *
197
+ * These are the facts the framework already holds at authorisation time. Before
198
+ * #202 a consumer had to reconstruct both of them by string-matching a URL, and
199
+ * five independent fail-open variants of that reconstruction were found in one
200
+ * three-line documented example -- each one wrong in the direction that GRANTS
201
+ * access. Read these instead; there is nothing to parse and no variant to miss.
202
+ *
203
+ * `record` is deliberately NOT a member. `auth()` runs after route matching but
204
+ * before any handler executes (`@stonyx/rest-server` `src/request.ts:58-60`),
205
+ * so nothing has been fetched yet -- carrying a record here would force a
206
+ * pre-fetch on every request. It is also unnecessary: the `(record) => boolean`
207
+ * return shape of {@link AccessMethod} already IS the per-record hook, applied
208
+ * by the handlers. Auth-time and record-time are separate decision points.
209
+ */
210
+ export interface AccessContext {
211
+ /**
212
+ * The model this route was mounted for, e.g. `'owner'` or `'phone-number'`.
213
+ *
214
+ * Model names are kebab-case, as declared under `config.orm.paths.model` and
215
+ * keyed in the store -- NOT the pluralised, mount-prefixed route name. It is
216
+ * read from the `OrmRequest` instance and is never derived from the request
217
+ * target, so a mount prefix, a case-varied path, a query string or an
218
+ * absolute-form request-target cannot change it.
219
+ */
220
+ model: string;
221
+ /**
222
+ * The operation being authorised. Exactly one of the four {@link
223
+ * AccessOperation} verbs, or `undefined`. These are exactly the values of
224
+ * `methodAccessMap` in `src/orm-request.ts`, which is also what the
225
+ * permission-array return shape is matched against -- so the two forms cannot
226
+ * disagree.
227
+ *
228
+ * NOT the hook vocabulary. `HookContext.operation` (`src/hooks.ts`) carries
229
+ * `'list' | 'get' | 'create' | 'update' | 'delete'` on an identically-named
230
+ * key of an identically-shaped context object, and the access vocabulary
231
+ * collapses `list` and `get` into `'read'`. For one `GET /animals/1` a hook
232
+ * sees `'get'` and `access()` sees `'read'`. "No second vocabulary" is a
233
+ * statement about the ACCESS path only.
234
+ *
235
+ * `undefined` when the dispatched method has no entry in that map. Express
236
+ * delivers `HEAD` to the `GET` handler, so this is reachable. It is left
237
+ * undefined rather than defaulted on purpose: a fabricated `'read'` would
238
+ * turn an unclassified request into an authorised one.
239
+ *
240
+ * The KEY is required even though the value may be undefined: `auth()` always
241
+ * sets it, and a context that simply omitted it would be indistinguishable
242
+ * from one that classified the request and found nothing.
243
+ */
244
+ operation: AccessOperation | undefined;
245
+ }
246
+ /**
247
+ * A consumer `access()` predicate.
248
+ *
249
+ * The second argument is ADDITIVE: JavaScript ignores extra arguments, so every
250
+ * pre-#202 single-argument predicate keeps working untouched. Changing the
251
+ * FIRST argument instead would have been the breaking form, and a predicate
252
+ * that can no longer identify its collection falls through to a full CRUD
253
+ * grant -- so the "safer" breaking change would have converted every unmigrated
254
+ * predicate into a fail-open.
255
+ *
256
+ * `context` is nonetheless REQUIRED in the type, and that costs back-compat
257
+ * nothing. TypeScript already lets a fewer-parameter implementation satisfy a
258
+ * more-parameter signature, so an arity-1 predicate assigns to this type
259
+ * cleanly -- measured under `--strict`. What the `?` bought was the opposite of
260
+ * safety: it silently permitted `getAccess('animal')?.(request)` at the CALL
261
+ * site, i.e. exactly the omission {@link AccessContext} exists to prevent, and
262
+ * that call gets the model-wrong answer. Required, a caller that drops the
263
+ * context gets `TS2554: Expected 2 arguments, but got 1`.
264
+ */
265
+ export type AccessFunction = (request: unknown, context: AccessContext) => AccessMethod;
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.6",
7
+ "version": "0.3.2-alpha.61",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -61,16 +61,25 @@
61
61
  },
62
62
  "homepage": "https://github.com/abofs/stonyx-orm#readme",
63
63
  "dependencies": {
64
- "@stonyx/cron": "0.2.1-beta.53",
65
- "@stonyx/events": "0.1.1-beta.47",
66
- "stonyx": "0.2.3-beta.61"
64
+ "@stonyx/cron": "0.2.1-beta.85",
65
+ "@stonyx/events": "0.1.1-beta.52",
66
+ "@stonyx/utils": "0.2.3-beta.26",
67
+ "stonyx": "0.2.3-beta.77"
67
68
  },
68
69
  "peerDependencies": {
70
+ "@aws-sdk/client-dynamodb": "^3.0.0",
71
+ "@aws-sdk/lib-dynamodb": "^3.0.0",
69
72
  "@stonyx/rest-server": ">=0.2.1-beta.14",
70
73
  "mysql2": "^3.0.0",
71
74
  "pg": "^8.0.0"
72
75
  },
73
76
  "peerDependenciesMeta": {
77
+ "@aws-sdk/client-dynamodb": {
78
+ "optional": true
79
+ },
80
+ "@aws-sdk/lib-dynamodb": {
81
+ "optional": true
82
+ },
74
83
  "mysql2": {
75
84
  "optional": true
76
85
  },
@@ -82,8 +91,7 @@
82
91
  }
83
92
  },
84
93
  "devDependencies": {
85
- "@stonyx/rest-server": "0.2.1-beta.53",
86
- "@stonyx/utils": "0.2.3-beta.23",
94
+ "@stonyx/rest-server": "0.2.1-beta.84",
87
95
  "@types/node": "^25.6.0",
88
96
  "mysql2": "^3.20.0",
89
97
  "pg": "^8.20.0",
@@ -95,6 +103,7 @@
95
103
  "scripts": {
96
104
  "build": "tsc",
97
105
  "build:test": "tsc -p tsconfig.test.json",
98
- "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'",
107
+ "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'"
99
108
  }
100
109
  }