@stonyx/orm 0.3.2-alpha.7 → 0.3.2-alpha.71
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 +968 -11
- package/config/environment.js +8 -0
- package/dist/access-verdict.d.ts +59 -0
- package/dist/access-verdict.js +222 -0
- package/dist/commands.js +34 -0
- package/dist/dynamodb/connection.d.ts +31 -0
- package/dist/dynamodb/connection.js +28 -0
- package/dist/dynamodb/dynamodb-db.d.ts +142 -0
- package/dist/dynamodb/dynamodb-db.js +596 -0
- package/dist/dynamodb/operation-builder.d.ts +76 -0
- package/dist/dynamodb/operation-builder.js +116 -0
- package/dist/dynamodb/type-map.d.ts +31 -0
- package/dist/dynamodb/type-map.js +48 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +8 -0
- package/dist/main.d.ts +116 -0
- package/dist/main.js +129 -0
- package/dist/manage-record.js +268 -12
- 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/orm-request.d.ts +216 -3
- package/dist/orm-request.js +924 -55
- package/dist/postgres/connection.d.ts +1 -0
- package/dist/postgres/connection.js +8 -6
- package/dist/postgres/postgres-db.d.ts +8 -0
- package/dist/postgres/postgres-db.js +44 -10
- package/dist/record.d.ts +16 -0
- package/dist/record.js +62 -6
- package/dist/relationships.js +1 -1
- package/dist/serializer.js +38 -2
- package/dist/setup-rest-server.js +51 -5
- package/dist/standalone-db.js +17 -5
- package/dist/store.d.ts +13 -1
- package/dist/store.js +65 -6
- package/dist/types/orm-types.d.ts +139 -0
- package/dist/utils.d.ts +44 -0
- package/dist/utils.js +47 -0
- package/package.json +16 -7
- package/src/access-verdict.ts +248 -0
- package/src/commands.ts +43 -0
- package/src/dynamodb/connection.ts +50 -0
- package/src/dynamodb/dynamodb-db.ts +811 -0
- package/src/dynamodb/operation-builder.ts +202 -0
- package/src/dynamodb/type-map.ts +54 -0
- package/src/index.ts +10 -0
- package/src/main.ts +133 -0
- package/src/manage-record.ts +294 -18
- package/src/mysql/connection.ts +1 -0
- package/src/mysql/mysql-db.ts +44 -12
- package/src/orm-request.ts +944 -56
- package/src/postgres/connection.ts +10 -6
- package/src/postgres/postgres-db.ts +44 -12
- package/src/record.ts +82 -6
- package/src/relationships.ts +1 -1
- package/src/serializer.ts +39 -2
- package/src/setup-rest-server.ts +59 -6
- package/src/standalone-db.ts +17 -6
- package/src/store.ts +68 -6
- package/src/types/orm-types.ts +146 -1
- package/src/types/stonyx-rest-server.d.ts +14 -1
- package/src/types/stonyx.d.ts +7 -1
- package/src/utils.ts +50 -0
|
@@ -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
|
|
12
|
-
port
|
|
13
|
-
user
|
|
14
|
-
password
|
|
15
|
-
database
|
|
16
|
-
max:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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.d.ts
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import type Serializer from './serializer.js';
|
|
2
|
+
import type { LinkageFilter } from './types/orm-types.js';
|
|
2
3
|
interface ToJSONOptions {
|
|
3
4
|
fields?: Set<string>;
|
|
4
5
|
baseUrl?: string;
|
|
6
|
+
/**
|
|
7
|
+
* An ALREADY-RESOLVED linkage decision, supplied by a caller that holds the
|
|
8
|
+
* request (abofs/stonyx-orm#234). Returning `false` for a related record
|
|
9
|
+
* drops that record's `{ type, id }` from `relationships.*.data`.
|
|
10
|
+
*
|
|
11
|
+
* This method APPLIES a verdict; it never RESOLVES one -- see
|
|
12
|
+
* `src/access-verdict.ts` for the two measured reasons it cannot. ABSENT is
|
|
13
|
+
* the default and the default is TODAY'S DOCUMENT, unchanged, because
|
|
14
|
+
* `toJSON` is also the `JSON.stringify` hook and an implicit caller has no
|
|
15
|
+
* syntactic place to pass this (abofs/stonyx-orm#230).
|
|
16
|
+
*
|
|
17
|
+
* ABSENT and UNUSABLE are read differently, and the difference is a security
|
|
18
|
+
* decision -- see the three-way reading at the call site below.
|
|
19
|
+
*/
|
|
20
|
+
linkage?: LinkageFilter;
|
|
5
21
|
}
|
|
6
22
|
interface SerializeOptions {
|
|
7
23
|
update?: boolean;
|
package/dist/record.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { store } from '@stonyx/orm';
|
|
2
|
+
import log from 'stonyx/log';
|
|
2
3
|
import { getComputedProperties } from "./serializer.js";
|
|
3
4
|
import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
4
5
|
import { getPluralName } from './plural-registry.js';
|
|
@@ -40,11 +41,13 @@ export default class Record {
|
|
|
40
41
|
const records = {};
|
|
41
42
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
42
43
|
if (Array.isArray(childRecord)) {
|
|
44
|
+
// Filter out cleaned records (those with no __model)
|
|
45
|
+
const live = childRecord.filter((r) => r?.__model);
|
|
43
46
|
// Deduplicate by record ID — keep last occurrence (latest data wins)
|
|
44
47
|
const seen = new Set();
|
|
45
48
|
const unique = [];
|
|
46
|
-
for (let i =
|
|
47
|
-
const r =
|
|
49
|
+
for (let i = live.length - 1; i >= 0; i--) {
|
|
50
|
+
const r = live[i];
|
|
48
51
|
if (!seen.has(r.id)) {
|
|
49
52
|
seen.add(r.id);
|
|
50
53
|
unique.push(r);
|
|
@@ -54,7 +57,7 @@ export default class Record {
|
|
|
54
57
|
records[key] = unique.map((r) => r.serialize());
|
|
55
58
|
}
|
|
56
59
|
else {
|
|
57
|
-
records[key] = childRecord?.serialize()
|
|
60
|
+
records[key] = childRecord?.__model ? childRecord.serialize() : null;
|
|
58
61
|
}
|
|
59
62
|
}
|
|
60
63
|
return { ...data, ...records };
|
|
@@ -63,7 +66,13 @@ export default class Record {
|
|
|
63
66
|
toJSON(options = {}) {
|
|
64
67
|
if (!this.__serialized)
|
|
65
68
|
throw new Error('Record must be serialized before being converted to JSON');
|
|
66
|
-
|
|
69
|
+
// DESTRUCTURED FROM A VALUE THAT IS NOT ALWAYS AN OBJECT. `toJSON` is the
|
|
70
|
+
// ECMAScript serialization hook, so `JSON.stringify({ data: record })`
|
|
71
|
+
// arrives here as `toJSON('data')` -- a STRING in the options slot.
|
|
72
|
+
// Destructuring a string yields `undefined` for every key, which is exactly
|
|
73
|
+
// the no-argument default, so the implicit path keeps working and keeps
|
|
74
|
+
// emitting today's document (abofs/stonyx-orm#230).
|
|
75
|
+
const { fields, baseUrl, linkage } = options;
|
|
67
76
|
const { __data: data } = this;
|
|
68
77
|
const modelName = this.__model.__name;
|
|
69
78
|
const pluralizedModelName = getPluralName(modelName);
|
|
@@ -82,12 +91,59 @@ export default class Record {
|
|
|
82
91
|
continue;
|
|
83
92
|
attributes[key] = getter.call(this);
|
|
84
93
|
}
|
|
94
|
+
// `linkage` is a PUBLIC option -- it is on `OrmRecord.toJSON`
|
|
95
|
+
// (src/types/orm-types.ts) and the README tells consumers to pass one -- so
|
|
96
|
+
// it arrives from outside this package and may be ANY value. Three
|
|
97
|
+
// readings, and the difference between the second and the third is a
|
|
98
|
+
// security decision:
|
|
99
|
+
//
|
|
100
|
+
// ABSENT (`undefined`). No verdict was supplied. Emit today's document.
|
|
101
|
+
// Load-bearing and asserted (AC5/AC5b): `toJSON` is also the
|
|
102
|
+
// `JSON.stringify` hook, so the implicit caller arrives as
|
|
103
|
+
// `toJSON('data')` -- a STRING, which destructures to `undefined` here
|
|
104
|
+
// (abofs/stonyx-orm#230).
|
|
105
|
+
//
|
|
106
|
+
// A FUNCTION. Apply it per related record.
|
|
107
|
+
//
|
|
108
|
+
// ANYTHING ELSE -- `null`, `0`, `false`, `''`, `true`, a string, an
|
|
109
|
+
// object. DENY, and say so. Neither of the two obvious alternatives is
|
|
110
|
+
// available. Reading it as absent is what `!linkage ||` did, and a
|
|
111
|
+
// resolver returning `null` because it could not resolve a session is the
|
|
112
|
+
// natural shape of that value and the fail-closed INTENT -- measured,
|
|
113
|
+
// `toJSON({ linkage: null })` emitted the full pre-#234 linkage with no
|
|
114
|
+
// signal, byte-identical to unpatched dev. Reading it as a function
|
|
115
|
+
// raises `TypeError: linkage is not a function` out of the enclosing
|
|
116
|
+
// `JSON.stringify` -- measured on `true`, `'x'` and `{}` -- which is
|
|
117
|
+
// exactly the outcome the comment below promises cannot happen.
|
|
118
|
+
//
|
|
119
|
+
// Logged once per DOCUMENT, not once per relationship key or per related
|
|
120
|
+
// record: an emptied relationship is deliberately indistinguishable from a
|
|
121
|
+
// genuinely empty one on the wire, so the log is the only signal a consumer
|
|
122
|
+
// whose resolver silently returned `null` will ever get.
|
|
123
|
+
const linkageSupplied = linkage !== undefined;
|
|
124
|
+
const linkageVerdict = !linkageSupplied
|
|
125
|
+
? undefined
|
|
126
|
+
: typeof linkage === 'function' ? linkage : () => false;
|
|
127
|
+
if (linkageSupplied && typeof linkage !== 'function') {
|
|
128
|
+
log.error?.(`[@stonyx/orm] toJSON() received a \`linkage\` option of type ${linkage === null ? 'null' : typeof linkage} -- it must be a function, so ALL relationship linkage on this \`${modelName}\` document is denied.`);
|
|
129
|
+
}
|
|
85
130
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
86
131
|
if (fields && !fields.has(key))
|
|
87
132
|
continue;
|
|
133
|
+
// The linkage decision is applied HERE, alongside the existing
|
|
134
|
+
// `__model` liveness check, and it produces exactly the shapes that
|
|
135
|
+
// check already produces: a dropped hasMany member leaves `data: []`,
|
|
136
|
+
// a dropped belongsTo leaves `data: null`. Both already ship -- a
|
|
137
|
+
// genuinely-empty hasMany emits `data: []` with links, and a cleaned
|
|
138
|
+
// belongsTo emits `data: null` -- so a filtered relationship is
|
|
139
|
+
// BYTE-IDENTICAL to an empty one and there is no new wire shape and no
|
|
140
|
+
// oracle. It never throws: a throw here escapes the enclosing
|
|
141
|
+
// `JSON.stringify` and takes `console.log` and `Orm.db.save()`'s
|
|
142
|
+
// neighbours with it, which is a far worse failure mode than a status.
|
|
143
|
+
const isLinkable = (r) => !linkageVerdict || linkageVerdict(r.__model.__name, r);
|
|
88
144
|
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;
|
|
145
|
+
? childRecord.filter((r) => r?.__model).filter(isLinkable).map((r) => ({ type: r.__model.__name, id: r.id }))
|
|
146
|
+
: (childRecord && childRecord.__model && isLinkable(childRecord)) ? { type: childRecord.__model.__name, id: childRecord.id } : null;
|
|
91
147
|
// Dasherize the key for URL paths (e.g., accessLinks -> access-links)
|
|
92
148
|
const dasherizedKey = camelCaseToKebabCase(key);
|
|
93
149
|
relationships[dasherizedKey] = { data: relationshipData };
|
package/dist/relationships.js
CHANGED
|
@@ -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'];
|
package/dist/serializer.js
CHANGED
|
@@ -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
|
-
|
|
83
|
-
|
|
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
|
|
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 (
|
|
28
|
+
if (accessFunctions[model])
|
|
29
29
|
throw new Error(`Access for model "${model}" has already been defined by another access class.`);
|
|
30
|
-
|
|
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(
|
|
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/standalone-db.js
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'fs/promises';
|
|
9
9
|
import path from 'path';
|
|
10
|
+
// `./utils.js` pulls in `@stonyx/utils/string` and nothing else -- no ORM
|
|
11
|
+
// bootstrap, no `@stonyx/orm` index, no side effects -- so the "no framework
|
|
12
|
+
// dependencies" property above still holds.
|
|
13
|
+
import { maxNumericId } from './utils.js';
|
|
10
14
|
export default class StandaloneDB {
|
|
11
15
|
mode;
|
|
12
16
|
dbPath;
|
|
@@ -102,11 +106,19 @@ export default class StandaloneDB {
|
|
|
102
106
|
async create(collection, data) {
|
|
103
107
|
const records = await this.readCollection(collection);
|
|
104
108
|
if (!data.id) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
// SHARED WITH `assignRecordId` (src/manage-record.ts), which is the other
|
|
110
|
+
// place this repo picks a server-assigned id. It was a second copy of the
|
|
111
|
+
// reduce, and nothing here pointed at it — a maintainer editing this
|
|
112
|
+
// method could not discover the other existed. See `maxNumericId` for why
|
|
113
|
+
// it is not `Math.max` (abofs/stonyx-orm#203).
|
|
114
|
+
//
|
|
115
|
+
// THE TWO ARE NOT THE SAME FUNCTION beyond this line, deliberately.
|
|
116
|
+
// `StandaloneDB` has no model, id-type or transform concept, so `maxId + 1`
|
|
117
|
+
// IS its store key; `assignRecordId` has to map the candidate through the
|
|
118
|
+
// model's declared id transform first, and then walk past occupied keys.
|
|
119
|
+
// Transplanting this method's remaining logic into the ORM reproduces
|
|
120
|
+
// #203's landing-key defect exactly — which is what AC4 pins.
|
|
121
|
+
data.id = maxNumericId(records) + 1;
|
|
110
122
|
}
|
|
111
123
|
// Check for duplicate id
|
|
112
124
|
const existing = records.find(r => r.id === data.id);
|
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
|
|
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 (
|
|
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
|