@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
@@ -0,0 +1,202 @@
1
+ /**
2
+ * DynamoDB operation parameter builders.
3
+ *
4
+ * Each function returns a plain-object "params" bag that can be passed
5
+ * directly to the corresponding DocumentClient command
6
+ * (PutCommand, GetCommand, UpdateCommand, DeleteCommand, ScanCommand, QueryCommand).
7
+ *
8
+ * All functions are pure — no SDK imports here; the caller wraps params
9
+ * in the appropriate Command class.
10
+ */
11
+
12
+ export interface PutItemParams {
13
+ TableName: string;
14
+ Item: Record<string, unknown>;
15
+ ConditionExpression?: string;
16
+ }
17
+
18
+ export interface GetItemParams {
19
+ TableName: string;
20
+ Key: Record<string, unknown>;
21
+ }
22
+
23
+ export interface UpdateItemParams {
24
+ TableName: string;
25
+ Key: Record<string, unknown>;
26
+ UpdateExpression: string;
27
+ ExpressionAttributeNames: Record<string, string>;
28
+ ExpressionAttributeValues: Record<string, unknown>;
29
+ ReturnValues: string;
30
+ }
31
+
32
+ export interface DeleteItemParams {
33
+ TableName: string;
34
+ Key: Record<string, unknown>;
35
+ }
36
+
37
+ export interface ScanParams {
38
+ TableName: string;
39
+ FilterExpression?: string;
40
+ ExpressionAttributeNames?: Record<string, string>;
41
+ ExpressionAttributeValues?: Record<string, unknown>;
42
+ ExclusiveStartKey?: Record<string, unknown>;
43
+ }
44
+
45
+ export interface QueryParams {
46
+ TableName: string;
47
+ IndexName: string;
48
+ KeyConditionExpression: string;
49
+ ExpressionAttributeNames: Record<string, string>;
50
+ ExpressionAttributeValues: Record<string, unknown>;
51
+ ExclusiveStartKey?: Record<string, unknown>;
52
+ }
53
+
54
+ /**
55
+ * PutItem — optionally with a condition expression.
56
+ *
57
+ * Pass conditionExpression = 'attribute_not_exists(id)' to enforce uniqueness.
58
+ */
59
+ export function buildPutItem(
60
+ tableName: string,
61
+ item: Record<string, unknown>,
62
+ conditionExpression?: string,
63
+ ): PutItemParams {
64
+ const params: PutItemParams = { TableName: tableName, Item: item };
65
+ if (conditionExpression) params.ConditionExpression = conditionExpression;
66
+ return params;
67
+ }
68
+
69
+ /**
70
+ * GetItem by primary key.
71
+ */
72
+ export function buildGetItem(
73
+ tableName: string,
74
+ key: Record<string, unknown>,
75
+ ): GetItemParams {
76
+ return { TableName: tableName, Key: key };
77
+ }
78
+
79
+ /**
80
+ * UpdateItem with a SET expression built from the `updates` object.
81
+ * Only the supplied attributes are updated (diff-based call site).
82
+ */
83
+ export function buildUpdateItem(
84
+ tableName: string,
85
+ key: Record<string, unknown>,
86
+ updates: Record<string, unknown>,
87
+ ): UpdateItemParams {
88
+ const names: Record<string, string> = {};
89
+ const values: Record<string, unknown> = {};
90
+ const setClauses: string[] = [];
91
+
92
+ for (const [attr, val] of Object.entries(updates)) {
93
+ const nameAlias = `#${attr}`;
94
+ const valAlias = `:${attr}`;
95
+ names[nameAlias] = attr;
96
+ values[valAlias] = val;
97
+ setClauses.push(`${nameAlias} = ${valAlias}`);
98
+ }
99
+
100
+ return {
101
+ TableName: tableName,
102
+ Key: key,
103
+ UpdateExpression: `SET ${setClauses.join(', ')}`,
104
+ ExpressionAttributeNames: names,
105
+ ExpressionAttributeValues: values,
106
+ ReturnValues: 'NONE',
107
+ };
108
+ }
109
+
110
+ /**
111
+ * DeleteItem by primary key.
112
+ */
113
+ export function buildDeleteItem(
114
+ tableName: string,
115
+ key: Record<string, unknown>,
116
+ ): DeleteItemParams {
117
+ return { TableName: tableName, Key: key };
118
+ }
119
+
120
+ /**
121
+ * ScanCommand params.
122
+ * If conditions are supplied they are rendered as a FilterExpression using AND.
123
+ */
124
+ export function buildScan(
125
+ tableName: string,
126
+ conditions?: Record<string, unknown>,
127
+ exclusiveStartKey?: Record<string, unknown>,
128
+ ): ScanParams {
129
+ const params: ScanParams = { TableName: tableName };
130
+
131
+ if (exclusiveStartKey) params.ExclusiveStartKey = exclusiveStartKey;
132
+
133
+ if (conditions && Object.keys(conditions).length > 0) {
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;
154
+ }
155
+ }
156
+
157
+ return params;
158
+ }
159
+
160
+ /**
161
+ * QueryCommand params for a GSI.
162
+ * keyConditions must be in the form { attrName: value } and will be rendered
163
+ * as equality expressions joined by AND.
164
+ */
165
+ export function buildQuery(
166
+ tableName: string,
167
+ indexName: string,
168
+ keyConditions: Record<string, unknown>,
169
+ exclusiveStartKey?: Record<string, unknown>,
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
+
179
+ const names: Record<string, string> = {};
180
+ const values: Record<string, unknown> = {};
181
+ const clauses: string[] = [];
182
+
183
+ for (const [attr, val] of validEntries) {
184
+ const nameAlias = `#${attr}`;
185
+ const valAlias = `:${attr}`;
186
+ names[nameAlias] = attr;
187
+ values[valAlias] = val;
188
+ clauses.push(`${nameAlias} = ${valAlias}`);
189
+ }
190
+
191
+ const params: QueryParams = {
192
+ TableName: tableName,
193
+ IndexName: indexName,
194
+ KeyConditionExpression: clauses.join(' AND '),
195
+ ExpressionAttributeNames: names,
196
+ ExpressionAttributeValues: values,
197
+ };
198
+
199
+ if (exclusiveStartKey) params.ExclusiveStartKey = exclusiveStartKey;
200
+
201
+ return params;
202
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Maps ORM attribute types to DynamoDB scalar attribute types.
3
+ * DynamoDB DocumentClient auto-marshalls JS objects, so most values
4
+ * are sent as their native JS types. This map is used by the
5
+ * schema-introspector and startup provisioner for table/GSI creation.
6
+ */
7
+
8
+ export type DynamoScalarType = 'S' | 'N' | 'BOOL';
9
+
10
+ /**
11
+ * DynamoDB attribute-type string for a given ORM attr type.
12
+ * - string → S
13
+ * - number / float → N (stored as Number; DocumentClient handles it)
14
+ * - boolean → BOOL
15
+ * - date → S (ISO-8601 string — enables range queries)
16
+ * - timestamp → N (milliseconds since epoch)
17
+ * - passthrough/trim/etc → S (safe default)
18
+ *
19
+ * For key schema declarations only `S` and `N` are valid; BOOL
20
+ * is legal for attributes but never for a PK/SK.
21
+ */
22
+ const typeMap: Record<string, DynamoScalarType> = {
23
+ string: 'S',
24
+ number: 'N',
25
+ float: 'N',
26
+ boolean: 'BOOL',
27
+ date: 'S',
28
+ timestamp: 'N',
29
+ passthrough: 'S',
30
+ trim: 'S',
31
+ uppercase: 'S',
32
+ ceil: 'N',
33
+ floor: 'N',
34
+ round: 'N',
35
+ };
36
+
37
+ /**
38
+ * Returns the DynamoDB attribute type for a given ORM type string.
39
+ * Defaults to 'S' for any unknown/custom type.
40
+ */
41
+ export function getDynamoType(attrType: string): DynamoScalarType {
42
+ return typeMap[attrType] ?? 'S';
43
+ }
44
+
45
+ /**
46
+ * Returns the DynamoDB key type ('S' | 'N') for use in KeySchema.
47
+ * BOOL cannot be a key attribute; anything that maps to BOOL falls back to 'S'.
48
+ */
49
+ export function getDynamoKeyType(attrType: string): 'S' | 'N' {
50
+ const t = getDynamoType(attrType);
51
+ return t === 'N' ? 'N' : 'S';
52
+ }
53
+
54
+ export default typeMap;
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import { count, avg, sum, min, max } from './aggregates.js';
27
27
  export { default } from './main.js';
28
28
  export { store, relationships } from './main.js';
29
29
  export type { PersistErrorDetail } from './main.js';
30
+ export type { AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js'; // access() contract (#202)
30
31
  export { Model, View, Serializer }; // base classes
31
32
  export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
32
33
  export { count, avg, sum, min, max }; // aggregate helpers
package/src/main.ts CHANGED
@@ -25,6 +25,7 @@ import baseTransforms from './transforms.js';
25
25
  import Store from './store.js';
26
26
  import Serializer from './serializer.js';
27
27
  import { setup } from '@stonyx/events';
28
+ import type { AccessFunction } from './types/orm-types.js';
28
29
 
29
30
  interface OrmOptions {
30
31
  dbType?: string;
@@ -68,6 +69,53 @@ export default class Orm {
68
69
  views: Record<string, unknown> = {};
69
70
  transforms: Record<string, (value: unknown) => unknown> = { ...baseTransforms };
70
71
  warnings: Set<string> = new Set();
72
+
73
+ /**
74
+ * Model name -> the `access` predicate of the access class that CLAIMS that
75
+ * model (abofs/stonyx-orm#202).
76
+ *
77
+ * Not "that model's own predicate". One access class may claim many models
78
+ * -- `GlobalAccess` in this repo's fixtures declares five, and `models = '*'`
79
+ * claims every model in the store -- and it declares ONE `access` method, so
80
+ * the same function object is registered under every one of those keys.
81
+ * `getAccess('owner') === getAccess('animal')` is `true` there. The
82
+ * one-to-one guarantee below is key -> function, never function -> model,
83
+ * and a caller must not read a resolved predicate as being animal-specific.
84
+ * What makes the ANSWER model-specific is the context the caller passes and
85
+ * the predicate actually reading it -- see {@link Orm#getAccess}.
86
+ *
87
+ * NAMED FOR WHAT IT HOLDS. It was `accessFiles` through review, inherited
88
+ * from the function-local in `setup-rest-server.ts` where the values came
89
+ * straight out of `forEachFileImport` and "files" was defensible. The values
90
+ * are `AccessFunction`s, and the sibling public registries on this class
91
+ * (`models`, `serializers`, `views`, `transforms`) are all plural nouns of
92
+ * the thing held. Renamed here because #202 is the last moment it is free.
93
+ *
94
+ * Populated by `setup-rest-server.ts` at boot, from the access classes under
95
+ * `config.orm.paths.access`, BEFORE any route is mounted -- so it is complete
96
+ * and reachable before the first request can be served. The mapping is
97
+ * one-to-one by construction: setup-rest-server throws if two access classes
98
+ * claim the same model.
99
+ *
100
+ * Keys are model names as declared and stored (kebab-case, e.g.
101
+ * `'phone-number'`), NOT pluralised or mount-prefixed route names.
102
+ *
103
+ * WHY THIS EXISTS AS A FIELD. It used to be a function-local in
104
+ * setup-rest-server that was discarded when that function returned, so at
105
+ * request time there was no way to get from a model name to that model's
106
+ * predicate at all. Each `OrmRequest` held only its OWN model's predicate.
107
+ * That made cross-model authorization -- asking model X's predicate about a
108
+ * request routed to model Y -- inexpressible, which is the capability
109
+ * abofs/stonyx-orm#196 and abofs/stonyx-orm#207 are built on.
110
+ *
111
+ * Empty when the REST server is disabled, and PARTIAL when one access file
112
+ * failed to load (`setup-rest-server.ts` catches, warns and assigns whatever
113
+ * it had). So a missing key does NOT mean the model has no access class.
114
+ * Prefer {@link Orm#getAccess} over indexing this directly -- it is guarded
115
+ * against the prototype chain and this is not.
116
+ */
117
+ accessFunctions: Record<string, AccessFunction> = {};
118
+
71
119
  options!: OrmOptions;
72
120
  sqlDb?: SqlDb;
73
121
  db?: OrmDB | SqlDb;
@@ -90,6 +138,11 @@ export default class Orm {
90
138
  }
91
139
 
92
140
  async init(): Promise<void> {
141
+ // Self-register so log.db works even when @stonyx/orm is in the
142
+ // consumer's `dependencies` (stonyx loader only merges devDependencies).
143
+ const { logColor = 'white', logMethod = 'db' } = config.orm;
144
+ log.defineType(logMethod, logColor);
145
+
93
146
  const { paths, restServer } = config.orm;
94
147
 
95
148
  const promises: Promise<unknown>[] = ['Model', 'Serializer', 'Transform'].map(type => {
@@ -159,6 +212,11 @@ export default class Orm {
159
212
  this.sqlDb = new MysqlDB() as SqlDb;
160
213
  this.db = this.sqlDb;
161
214
  promises.push(this.sqlDb.init());
215
+ } else if (config.orm.dynamodb) {
216
+ const { default: DynamoDBDB } = await import('./dynamodb/dynamodb-db.js');
217
+ this.sqlDb = new DynamoDBDB() as SqlDb;
218
+ this.db = this.sqlDb;
219
+ promises.push(this.sqlDb.init());
162
220
  } else if (this.options.dbType !== 'none') {
163
221
  const db = new DB();
164
222
  this.db = db;
@@ -185,6 +243,81 @@ export default class Orm {
185
243
  Orm.initialized = true;
186
244
  }
187
245
 
246
+ /**
247
+ * Resolve the `access` predicate registered for a model name
248
+ * (abofs/stonyx-orm#202).
249
+ *
250
+ * This is the supported way to reach another model's predicate while
251
+ * servicing a request routed to a different model. Call it with the model
252
+ * name and invoke the result with the live request and an explicit context
253
+ * naming THAT model:
254
+ *
255
+ * ```js
256
+ * const predicate = Orm.instance.getAccess('animal');
257
+ * if (!predicate) return deny;
258
+ * const verdict = predicate(request, { model: 'animal', operation: 'read' });
259
+ * ```
260
+ *
261
+ * WHAT IT RESOLVES. The predicate of the access CLASS that claims the model,
262
+ * which is not necessarily specific to it: one class may claim many models
263
+ * and declares one `access` method, so
264
+ * `getAccess('owner') === getAccess('animal')` is `true` against this repo's
265
+ * fixture. See {@link Orm#accessFunctions}.
266
+ *
267
+ * `undefined` means NO PREDICATE COULD BE RESOLVED for that name. That
268
+ * includes a model whose access class failed to LOAD -- `setup-rest-server`
269
+ * catches, warns and publishes the partial map -- so it is not the same claim
270
+ * as "this model is unrestricted". Treat it as DENY, the same way
271
+ * `AccessContext.operation === undefined` is treated.
272
+ *
273
+ * PASSING THE CONTEXT MAKES A MODEL-CORRECT ANSWER POSSIBLE. It does not, on
274
+ * its own, make the answer model-correct: the resolved predicate has to READ
275
+ * the context. Measured against this repo's shipped access class on a request
276
+ * express dispatched to `GET /owners/angela`, asked about ANIMALS:
277
+ *
278
+ * ```
279
+ * getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
280
+ * -> record => record.id !== 'angela' && record.id !== 'restricted'
281
+ * ```
282
+ *
283
+ * The OWNERS filter, which returns `true` for animal 21 -- the record hidden
284
+ * on every animal surface. Under a mount that predicate recognises neither
285
+ * way it falls through to `['read', 'create', 'update', 'delete']`, a full
286
+ * CRUD grant. Either way: context supplied, answer not the animal answer,
287
+ * wrong in the GRANTING direction, because that predicate is arity-1 and
288
+ * identifies its collection from the request. AC9 asserts the first case on a
289
+ * live dispatch.
290
+ *
291
+ * Every predicate in this repo and in every consumer tree is arity-1 today,
292
+ * and there is no supported way for the caller to tell which kind it got; the
293
+ * boot-time arity warning that would surface it is abofs/stonyx-orm#213. Pass
294
+ * the context, and do not treat a resolved predicate's answer as
295
+ * model-specific until that predicate reads it.
296
+ *
297
+ * OWN PROPERTIES ONLY. A bare `this.accessFunctions[modelName]` walks the
298
+ * prototype chain, so `getAccess('constructor')` resolved `Object` and
299
+ * `getAccess('toString')` resolved `Object.prototype.toString` -- both
300
+ * callable, and the documented `predicate?.(request, ctx)` pattern then
301
+ * returned a TRUTHY value (`Object(request)` is the request), bypassing the
302
+ * `undefined`-means-deny contract entirely. Nothing in the ORM calls
303
+ * `getAccess` yet, so it was not exploitable as shipped -- but #207 takes the
304
+ * model name from the REQUEST BODY (`data.relationships.<key>.data.type`),
305
+ * which would have made a one-field body an authorization bypass. Guarded
306
+ * here at the read point rather than by constructing the map with a null
307
+ * prototype, because the field is public and reassignable and the guard has
308
+ * to hold whatever object it is holding.
309
+ *
310
+ * @param modelName - Model name as declared and stored (kebab-case).
311
+ * @returns The predicate, or `undefined` when no predicate could be resolved
312
+ * for that name. `undefined` is NOT "this model is unrestricted" -- see the
313
+ * note above. Treat it as deny.
314
+ */
315
+ getAccess(modelName: string): AccessFunction | undefined {
316
+ if (!Object.hasOwn(this.accessFunctions, modelName)) return undefined;
317
+
318
+ return this.accessFunctions[modelName];
319
+ }
320
+
188
321
  async startup(): Promise<void> {
189
322
  if (this.sqlDb) await this.sqlDb.startup();
190
323
  }
@@ -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 hasManyReg = getHasManyRegistry();
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 = hasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
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).catch((err: unknown) => {
122
- orm!.emitPersistError({
123
- operation: 'create',
124
- modelName,
125
- recordId: record.id,
126
- error: err instanceof Error ? err : new Error(String(err)),
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;
@@ -163,17 +195,52 @@ export function updateRecord(record: OrmRecord, rawData: unknown, userOptions: C
163
195
  }
164
196
 
165
197
  /**
166
- * gets the next available id based on last record entry.
198
+ * gets the next available id, based on the HIGHEST id present.
167
199
  *
168
200
  * In MySQL mode with numeric IDs, assigns a temporary pending ID.
169
201
  * MySQL's AUTO_INCREMENT provides the real ID after INSERT.
202
+ *
203
+ * ---------------------------------------------------------------------------
204
+ * WAS: `Array.from(storeMap.values()).at(-1).id + 1` — the LAST INSERTED id,
205
+ * not the maximum (abofs/stonyx-orm#203). The store is a Map, so insertion
206
+ * order stops being ascending the moment a record is deleted and recreated, a
207
+ * db.json is written out of order, a directory-mode store is read back in file
208
+ * order, or a caller POSTs a high id and then a low one. After that, every
209
+ * server-assigned id is one that is ALREADY TAKEN — and `createRecord`'s
210
+ * last-entry-wins branch then overwrites that record IN PLACE and answers 200.
211
+ * No error, no 409, and the store's size does not change. That is the whole
212
+ * defect, and it is reachable from a create with NO id at all, which is the
213
+ * most ordinary write a consumer performs.
214
+ *
215
+ * Covered by test/unit/assign-record-id-test.ts. Note for anyone changing this
216
+ * function: before that file existed, the whole suite scored 951/0 both on the
217
+ * defect and on a naive `Math.max` fix that introduced a second one. A green
218
+ * suite is not evidence here; those assertions are.
219
+ * ---------------------------------------------------------------------------
170
220
  */
171
221
  function assignRecordId(modelName: string, rawData: { [key: string]: unknown }): void {
172
- if (rawData.id) return;
222
+ // PRESENCE, not truthiness. `0` is a legal value for an `attr('number')` id,
223
+ // and `if (rawData.id) return` silently reassigned it, handing the caller back
224
+ // a different record than the one it named (#203).
225
+ //
226
+ // `''` is deliberately NOT honoured here and this is not an oversight: it is
227
+ // the one string that means "no id". `parseInt('')` is `NaN`, a record CAN be
228
+ // held under the key `NaN`, and orm-request.ts's body-id normalisation relies
229
+ // on `''` staying absent — otherwise `POST {"id":""}` answers 409 against a
230
+ // record it never named. Pinned by test/unit/assign-record-id-test.ts (AC6's
231
+ // BOUNDARY assertions) and by access-filter-enforcement-test.ts assertion 44.
232
+ // Widening this to `!== undefined` breaks both.
233
+ if (rawData.id || rawData.id === 0) return;
173
234
 
174
235
  // In SQL mode with numeric IDs, defer to database auto-increment.
175
236
  // Use unique negative integers — they survive the number transform (parseInt preserves negatives)
176
237
  // and avoid NaN store-key collisions that string pending IDs caused.
238
+ //
239
+ // This early return is ABOVE the max computation on purpose: a pending
240
+ // negative must never be a candidate for, or be perturbed by, the max path.
241
+ // Pinned directly (AC5.3) rather than by asserting the max is unaffected —
242
+ // that assertion could not have failed, because nothing negative ever reaches
243
+ // the code below.
177
244
  if (Orm.instance?.sqlDb && !isStringIdModel(modelName)) {
178
245
  rawData.id = -(++pendingIdCounter);
179
246
  rawData.__pendingSqlId = true;
@@ -183,15 +250,85 @@ function assignRecordId(modelName: string, rawData: { [key: string]: unknown }):
183
250
  const storeMap = store.get(modelName);
184
251
  if (!storeMap) throw new Error(`Cannot assign record ID: model "${modelName}" not found in store`);
185
252
  const modelStore = Array.from(storeMap.values()).filter(isOrmRecord);
186
- const lastRecord = modelStore.at(-1);
187
- rawData.id = lastRecord ? (lastRecord.id as number) + 1 : 1;
253
+
254
+ // The shape of src/standalone-db.ts:134-137, and it is chosen over
255
+ // `Math.max(...ids)` for a reason that is measurable rather than stylistic:
256
+ // a store CAN hold a record under the key `NaN` (`{id: ' '}` is truthy, so
257
+ // it survives the guard above and NaNs in the number transform — that is the
258
+ // state access-filter-enforcement-test.ts assertion 44 constructs). `Math.max`
259
+ // returns `NaN` if any operand is `NaN`, so it would assign `NaN`, land on
260
+ // that slot and overwrite it — exactly the defect being fixed, in a new
261
+ // disguise. This reduce cannot: non-numbers are skipped, and `NaN > max` is
262
+ // `false`. Pinned by AC2.
263
+ const maxId = modelStore.reduce((max: number, record) => {
264
+ const recordId = record.id as unknown;
265
+
266
+ return typeof recordId === 'number' && recordId > max ? recordId : max;
267
+ }, 0);
268
+
269
+ // THE OCCUPANCY CHECK RUNS ON THE LANDING KEY, NOT ON THE RAW CANDIDATE, and
270
+ // the difference is a silent data loss rather than a nicety.
271
+ //
272
+ // `createRecord` looks the record up under `rawData.id` (:50) but WRITES it
273
+ // under `record.id` (:69) — the value after the model's declared id transform
274
+ // has run inside `serialize`. On a string-id model those two differ: the
275
+ // number `1` is looked up, the record lands under the string `'1'`. A guard
276
+ // written as `storeMap.has(rawData.id)` therefore checks a key the record will
277
+ // never occupy, misses an occupied slot and overwrites it — measured: owner
278
+ // '1' age 55 -> 9, store size unchanged, no error. That is abofs/stonyx-orm
279
+ // #205's lookup-key/landing-key divergence reappearing inside #203's own fix,
280
+ // which is why AC4 exists and why `rawData.id` is set to the LANDING key
281
+ // below: it makes :50 and :69 agree by construction.
282
+ //
283
+ // Termination: with an injective id transform at most `storeMap.size`
284
+ // candidates can be occupied. A NON-injective id type would otherwise spin
285
+ // forever, so the loop is bounded and exits with a defined error the route can
286
+ // report instead of hanging the request.
287
+ //
288
+ // Resolved ONCE, outside the loop: `getIdType` instantiates the model class,
289
+ // so resolving it per candidate would put a model construction on every
290
+ // iteration of a loop that exists to walk past occupied slots.
291
+ const toStoreKey = storeKeyDeriver(modelName);
292
+
293
+ let candidate = maxId + 1;
294
+ let landingKey = toStoreKey(candidate);
295
+ let attempts = 0;
296
+
297
+ while (storeMap.has(landingKey)) {
298
+ if (++attempts > storeMap.size) {
299
+ throw new Error(`Cannot assign record ID: no free id available for model "${modelName}"`);
300
+ }
301
+
302
+ candidate += 1;
303
+ landingKey = toStoreKey(candidate);
304
+ }
305
+
306
+ rawData.id = landingKey;
188
307
  }
189
308
 
190
- function isStringIdModel(modelName: string): boolean {
191
- const modelClass = Orm.instance.getRecordClasses(modelName).modelClass as (new (name: string) => { [key: string]: unknown }) | undefined;
192
- if (!modelClass) return false;
309
+ /**
310
+ * Returns the derivation that maps an id VALUE to the store KEY a record
311
+ * carrying it will actually be filed under — the model's declared id transform,
312
+ * the same one `serialize` runs at createRecord:68 before the `.set` at :69.
313
+ */
314
+ function storeKeyDeriver(modelName: string): (value: number) => number | string {
315
+ const idType = getIdType(modelName);
316
+ const transform = idType ? Orm.instance?.transforms?.[idType] : undefined;
317
+
318
+ if (typeof transform !== 'function') return value => value;
319
+
320
+ return value => transform(value) as number | string;
321
+ }
322
+
323
+ function getIdType(modelName: string): string | undefined {
324
+ const modelClass = Orm.instance?.getRecordClasses(modelName).modelClass as (new (name: string) => { [key: string]: unknown }) | undefined;
325
+ if (!modelClass) return undefined;
193
326
 
194
327
  const model = new modelClass(modelName);
195
328
 
196
- return (model.id as { type?: string } | undefined)?.type === 'string';
329
+ return (model.id as { type?: string } | undefined)?.type;
330
+ }
331
+
332
+ function isStringIdModel(modelName: string): boolean {
333
+ return getIdType(modelName) === 'string';
197
334
  }
@@ -9,6 +9,7 @@ interface MysqlConfig {
9
9
  connectionLimit?: number;
10
10
  migrationsTable?: string;
11
11
  migrationsDir?: string;
12
+ autoMigrate?: boolean;
12
13
  }
13
14
 
14
15
  let pool: Pool | null = null;