@stonyx/orm 0.3.2-beta.16 → 0.3.2-beta.160
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 +1409 -11
- package/config/environment.js +99 -12
- package/dist/access-verdict.d.ts +85 -0
- package/dist/access-verdict.js +284 -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/hooks.d.ts +15 -1
- 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 +274 -3
- package/dist/orm-request.js +1259 -65
- 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 +154 -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 +260 -0
- package/dist/utils.d.ts +44 -0
- package/dist/utils.js +47 -0
- package/package.json +16 -7
- package/src/access-verdict.ts +312 -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/hooks.ts +15 -1
- 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 +1281 -67
- package/src/postgres/connection.ts +10 -6
- package/src/postgres/postgres-db.ts +44 -12
- package/src/record.ts +182 -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 +268 -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
- package/config/environment.ts +0 -91
|
@@ -8,6 +8,7 @@ interface PgConfig {
|
|
|
8
8
|
password: string;
|
|
9
9
|
database: string;
|
|
10
10
|
connectionLimit: number;
|
|
11
|
+
[key: string]: unknown;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
let pool: PgPool | null = null;
|
|
@@ -20,15 +21,18 @@ export async function getPool(pgConfig: PgConfig, extensions: string[] = ['vecto
|
|
|
20
21
|
|
|
21
22
|
const { default: pg } = await import('pg');
|
|
22
23
|
|
|
24
|
+
const { host, port, user, password, database, connectionLimit, migrationsDir, migrationsTable, autoMigrate, ...poolOpts } = pgConfig;
|
|
25
|
+
|
|
23
26
|
pool = new pg.Pool({
|
|
24
|
-
host
|
|
25
|
-
port
|
|
26
|
-
user
|
|
27
|
-
password
|
|
28
|
-
database
|
|
29
|
-
max:
|
|
27
|
+
host,
|
|
28
|
+
port,
|
|
29
|
+
user,
|
|
30
|
+
password,
|
|
31
|
+
database,
|
|
32
|
+
max: connectionLimit,
|
|
30
33
|
idleTimeoutMillis: 30000,
|
|
31
34
|
connectionTimeoutMillis: 10000,
|
|
35
|
+
...poolOpts,
|
|
32
36
|
});
|
|
33
37
|
|
|
34
38
|
// Enable requested PostgreSQL extensions
|
|
@@ -90,6 +90,15 @@ export default class PostgresDB {
|
|
|
90
90
|
pool!: Pool | null;
|
|
91
91
|
pgConfig!: Record<string, unknown>;
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Promise-chain mutex for write serialization (#156).
|
|
95
|
+
* All persist() calls chain through this single queue so concurrent
|
|
96
|
+
* fire-and-forget writes never produce parallel transactions
|
|
97
|
+
* on FK-linked rows (which cause deadlocks).
|
|
98
|
+
* Reads are NOT affected — only persist() serializes.
|
|
99
|
+
*/
|
|
100
|
+
private _writeQueue: Promise<void> = Promise.resolve();
|
|
101
|
+
|
|
93
102
|
constructor(deps: Partial<PostgresDeps> = {}) {
|
|
94
103
|
const Ctor = this.constructor as typeof PostgresDB;
|
|
95
104
|
if (Ctor.instance) return Ctor.instance;
|
|
@@ -125,7 +134,15 @@ export default class PostgresDB {
|
|
|
125
134
|
if (pending.length > 0) {
|
|
126
135
|
this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
|
|
127
136
|
|
|
128
|
-
|
|
137
|
+
let shouldApply: boolean;
|
|
138
|
+
if (this.pgConfig.autoMigrate === true) {
|
|
139
|
+
shouldApply = true;
|
|
140
|
+
} else if (this.pgConfig.autoMigrate === false) {
|
|
141
|
+
shouldApply = false;
|
|
142
|
+
this.deps.log.warn?.(`autoMigrate is false — skipping ${pending.length} pending migration(s).`);
|
|
143
|
+
} else {
|
|
144
|
+
shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
|
|
145
|
+
}
|
|
129
146
|
|
|
130
147
|
if (shouldApply) {
|
|
131
148
|
for (const filename of pending) {
|
|
@@ -146,9 +163,17 @@ export default class PostgresDB {
|
|
|
146
163
|
const modelCount = Object.keys(schemas).length;
|
|
147
164
|
|
|
148
165
|
if (modelCount > 0) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
166
|
+
let shouldGenerate: boolean;
|
|
167
|
+
if (this.pgConfig.autoMigrate === true) {
|
|
168
|
+
shouldGenerate = true;
|
|
169
|
+
} else if (this.pgConfig.autoMigrate === false) {
|
|
170
|
+
shouldGenerate = false;
|
|
171
|
+
this.deps.log.warn?.(`autoMigrate is false — skipping initial migration generation for ${modelCount} model(s).`);
|
|
172
|
+
} else {
|
|
173
|
+
shouldGenerate = await this.deps.confirm(
|
|
174
|
+
`No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
152
177
|
|
|
153
178
|
if (shouldGenerate) {
|
|
154
179
|
const { generateMigration } = await import('./migration-generator.js');
|
|
@@ -468,14 +493,21 @@ export default class PostgresDB {
|
|
|
468
493
|
const Orm = (await import('@stonyx/orm')).default;
|
|
469
494
|
if ((Orm.instance as { isView?: (name: string) => boolean })?.isView?.(modelName)) return;
|
|
470
495
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
496
|
+
const work = async () => {
|
|
497
|
+
switch (operation) {
|
|
498
|
+
case 'create':
|
|
499
|
+
return this._persistCreate(modelName, context, response);
|
|
500
|
+
case 'update':
|
|
501
|
+
return this._persistUpdate(modelName, context, response);
|
|
502
|
+
case 'delete':
|
|
503
|
+
return this._persistDelete(modelName, context);
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
// Chain through the write queue — .then(work, work) ensures the queue
|
|
508
|
+
// advances even when a previous persist rejects (#156).
|
|
509
|
+
this._writeQueue = this._writeQueue.then(work, work);
|
|
510
|
+
return this._writeQueue;
|
|
479
511
|
}
|
|
480
512
|
|
|
481
513
|
private async _persistCreate(modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
|
package/src/record.ts
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
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';
|
|
5
6
|
import type Serializer from './serializer.js';
|
|
7
|
+
import type { LinkageFilter } from './types/orm-types.js';
|
|
6
8
|
|
|
7
9
|
interface ToJSONOptions {
|
|
8
10
|
fields?: Set<string>;
|
|
9
11
|
baseUrl?: string;
|
|
12
|
+
/**
|
|
13
|
+
* An ALREADY-RESOLVED linkage decision, supplied by a caller that holds the
|
|
14
|
+
* request (abofs/stonyx-orm#234). Returning `false` for a related record
|
|
15
|
+
* drops that record's `{ type, id }` from `relationships.*.data`.
|
|
16
|
+
*
|
|
17
|
+
* This method APPLIES a verdict; it never RESOLVES one -- see
|
|
18
|
+
* `src/access-verdict.ts` for the two measured reasons it cannot. ABSENT is
|
|
19
|
+
* the default and the default is TODAY'S DOCUMENT, unchanged, because
|
|
20
|
+
* `toJSON` is also the `JSON.stringify` hook and an implicit caller has no
|
|
21
|
+
* syntactic place to pass this (abofs/stonyx-orm#230).
|
|
22
|
+
*
|
|
23
|
+
* ABSENT and UNUSABLE are read differently, and the difference is a security
|
|
24
|
+
* decision -- see the three-way reading at the call site below.
|
|
25
|
+
*/
|
|
26
|
+
linkage?: LinkageFilter;
|
|
10
27
|
}
|
|
11
28
|
|
|
12
29
|
interface SerializeOptions {
|
|
@@ -38,6 +55,25 @@ interface JSONAPIResult {
|
|
|
38
55
|
links?: { self: string };
|
|
39
56
|
}
|
|
40
57
|
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Name a non-boolean `linkage` return for the one log line that reports it.
|
|
61
|
+
*
|
|
62
|
+
* A thenable is called out BY NAME because it is the shape a consumer produces
|
|
63
|
+
* by accident -- an `async` resolver, or one that returns the promise of an
|
|
64
|
+
* authorization lookup -- and the one whose truthiness silently GRANTED every
|
|
65
|
+
* relationship before the ANSWER was checked (abofs/stonyx-orm#234).
|
|
66
|
+
*/
|
|
67
|
+
function describeNonVerdict(verdict: unknown): string {
|
|
68
|
+
if (verdict === null) return 'null';
|
|
69
|
+
if (Array.isArray(verdict)) return 'an array';
|
|
70
|
+
|
|
71
|
+
if ((typeof verdict === 'object' || typeof verdict === 'function')
|
|
72
|
+
&& typeof (verdict as { then?: unknown }).then === 'function') return 'a Promise (or other thenable)';
|
|
73
|
+
|
|
74
|
+
return `a value of type ${typeof verdict}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
41
77
|
export default class Record {
|
|
42
78
|
/** @private */
|
|
43
79
|
__data: { [key: string]: unknown } = {};
|
|
@@ -87,12 +123,15 @@ export default class Record {
|
|
|
87
123
|
|
|
88
124
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
89
125
|
if (Array.isArray(childRecord)) {
|
|
126
|
+
// Filter out cleaned records (those with no __model)
|
|
127
|
+
const live = childRecord.filter((r: Record) => r?.__model);
|
|
128
|
+
|
|
90
129
|
// Deduplicate by record ID — keep last occurrence (latest data wins)
|
|
91
130
|
const seen = new Set<unknown>();
|
|
92
131
|
const unique: Record[] = [];
|
|
93
132
|
|
|
94
|
-
for (let i =
|
|
95
|
-
const r =
|
|
133
|
+
for (let i = live.length - 1; i >= 0; i--) {
|
|
134
|
+
const r = live[i] as Record;
|
|
96
135
|
if (!seen.has(r.id)) {
|
|
97
136
|
seen.add(r.id);
|
|
98
137
|
unique.push(r);
|
|
@@ -102,7 +141,7 @@ export default class Record {
|
|
|
102
141
|
unique.reverse();
|
|
103
142
|
records[key] = unique.map((r: Record) => r.serialize());
|
|
104
143
|
} else {
|
|
105
|
-
records[key] = (childRecord as Record)?.serialize()
|
|
144
|
+
records[key] = (childRecord as Record)?.__model ? (childRecord as Record).serialize() : null;
|
|
106
145
|
}
|
|
107
146
|
}
|
|
108
147
|
|
|
@@ -113,7 +152,13 @@ export default class Record {
|
|
|
113
152
|
toJSON(options: ToJSONOptions = {}): JSONAPIResult {
|
|
114
153
|
if (!this.__serialized) throw new Error('Record must be serialized before being converted to JSON');
|
|
115
154
|
|
|
116
|
-
|
|
155
|
+
// DESTRUCTURED FROM A VALUE THAT IS NOT ALWAYS AN OBJECT. `toJSON` is the
|
|
156
|
+
// ECMAScript serialization hook, so `JSON.stringify({ data: record })`
|
|
157
|
+
// arrives here as `toJSON('data')` -- a STRING in the options slot.
|
|
158
|
+
// Destructuring a string yields `undefined` for every key, which is exactly
|
|
159
|
+
// the no-argument default, so the implicit path keeps working and keeps
|
|
160
|
+
// emitting today's document (abofs/stonyx-orm#230).
|
|
161
|
+
const { fields, baseUrl, linkage } = options;
|
|
117
162
|
const { __data: data } = this;
|
|
118
163
|
const modelName = this.__model.__name;
|
|
119
164
|
const pluralizedModelName = getPluralName(modelName);
|
|
@@ -132,12 +177,143 @@ export default class Record {
|
|
|
132
177
|
attributes[key] = (getter as () => unknown).call(this);
|
|
133
178
|
}
|
|
134
179
|
|
|
180
|
+
// `linkage` is a PUBLIC option -- it is on `OrmRecord.toJSON`
|
|
181
|
+
// (src/types/orm-types.ts) and the README tells consumers to pass one -- so
|
|
182
|
+
// it arrives from outside this package, may be ANY value, and whatever it
|
|
183
|
+
// is, it gets INVOKED here. That makes this the trust boundary, and it was
|
|
184
|
+
// the LAX side of one: the internal `createLinkageFilter` coerces and
|
|
185
|
+
// try/catches the consumer predicate it wraps, while this -- the site that
|
|
186
|
+
// consumes the PUBLIC option -- did neither.
|
|
187
|
+
//
|
|
188
|
+
// THREE QUESTIONS. Every wrong answer below was measured, on a two-
|
|
189
|
+
// relationship record, emitting the full pre-#234 document or throwing out
|
|
190
|
+
// of `JSON.stringify`.
|
|
191
|
+
//
|
|
192
|
+
// 1. IS IT SUPPLIED? ABSENT (`undefined`) means no verdict was supplied:
|
|
193
|
+
// emit today's document. Load-bearing and asserted (AC5/AC5b) --
|
|
194
|
+
// `toJSON` is also the `JSON.stringify` hook, so the implicit caller
|
|
195
|
+
// arrives as `toJSON('data')`, a STRING, which destructures to
|
|
196
|
+
// `undefined` here (abofs/stonyx-orm#230).
|
|
197
|
+
//
|
|
198
|
+
// 2. IS ITS SHAPE USABLE? `[object Function]` only, because
|
|
199
|
+
// `typeof x === 'function'` is NOT the question "can this answer a
|
|
200
|
+
// synchronous boolean".
|
|
201
|
+
//
|
|
202
|
+
// A NON-FUNCTION denies. Reading it as absent is what `!linkage ||`
|
|
203
|
+
// did, and a resolver returning `null` because it could not resolve a
|
|
204
|
+
// session is the natural shape of that value and the fail-closed
|
|
205
|
+
// INTENT -- measured, `toJSON({ linkage: null })` emitted the full
|
|
206
|
+
// pre-#234 linkage with no signal, byte-identical to unpatched dev.
|
|
207
|
+
//
|
|
208
|
+
// AN `AsyncFunction`, `GeneratorFunction` or `AsyncGeneratorFunction`
|
|
209
|
+
// denies for that SAME reason, one branch over -- and a `typeof`-only
|
|
210
|
+
// check left the whole defect standing there. `async (type, r) =>
|
|
211
|
+
// false` returns a PROMISE, a promise is TRUTHY, so every relationship
|
|
212
|
+
// was emitted in full with ZERO log, again byte-identical to unpatched
|
|
213
|
+
// dev. An awaited authorization lookup is at least as natural a
|
|
214
|
+
// resolver as a nullish one -- the README's own Consumer Contracts
|
|
215
|
+
// section points consumers at queue payloads and websocket frames,
|
|
216
|
+
// where lookups are routinely awaited -- and it landed on the GRANT
|
|
217
|
+
// side of the same branch the `null` reading closed.
|
|
218
|
+
//
|
|
219
|
+
// 3. IS ITS ANSWER A VERDICT? It must BE a boolean, not merely coerce to
|
|
220
|
+
// one. `Boolean(...)` -- the coercion `createLinkageFilter` applies to
|
|
221
|
+
// a consumer `access()` predicate, whose truthy contract predates this
|
|
222
|
+
// option and is deliberately NOT changed -- is not enough here, and
|
|
223
|
+
// was measured not to be: with `Boolean(...)` plus a try/catch in
|
|
224
|
+
// place, `async () => false`, `function* () {}`,
|
|
225
|
+
// `() => Promise.resolve(false)`, `() => ({})` and `() => 'no'` ALL
|
|
226
|
+
// still emitted the full pre-#234 linkage with no log, because
|
|
227
|
+
// truthiness is what they already had. A non-boolean is a resolver
|
|
228
|
+
// that did not answer, and the only safe reading of a non-answer is a
|
|
229
|
+
// denial.
|
|
230
|
+
//
|
|
231
|
+
// AND IT NEVER THROWS -- which is now true rather than only written down.
|
|
232
|
+
// A throw here escapes the enclosing `JSON.stringify` and takes
|
|
233
|
+
// `console.log` and `Orm.db.save()`'s neighbours with it, a far worse
|
|
234
|
+
// failure mode than a status. `class Klass {}`, `Klass.bind(null)` and any
|
|
235
|
+
// predicate that dereferences something undefined were all measured raising
|
|
236
|
+
// out of the `stringify`; all three are caught and denied.
|
|
237
|
+
//
|
|
238
|
+
// Logged once per DOCUMENT, not once per relationship key or per related
|
|
239
|
+
// record: an emptied relationship is deliberately indistinguishable from a
|
|
240
|
+
// genuinely empty one on the wire, so the log is the ONLY signal a consumer
|
|
241
|
+
// whose resolver quietly returned `null`, or a promise, will ever get.
|
|
242
|
+
const linkageSupplied = linkage !== undefined;
|
|
243
|
+
|
|
244
|
+
// Read the tag DEFENSIVELY. `Object.prototype.toString` consults
|
|
245
|
+
// `Symbol.toStringTag`, so a Proxy with a throwing `get` trap would throw
|
|
246
|
+
// out of the validation whose entire job is that nothing throws.
|
|
247
|
+
let linkageShape = 'a non-function';
|
|
248
|
+
|
|
249
|
+
if (typeof linkage === 'function') {
|
|
250
|
+
try {
|
|
251
|
+
linkageShape = Object.prototype.toString.call(linkage);
|
|
252
|
+
} catch {
|
|
253
|
+
linkageShape = '[object Unreadable]';
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const linkageUsable = linkageShape === '[object Function]';
|
|
258
|
+
|
|
259
|
+
let linkageReported = false;
|
|
260
|
+
|
|
261
|
+
const denyAllLinkage = (reason: string) => {
|
|
262
|
+
if (linkageReported) return;
|
|
263
|
+
linkageReported = true;
|
|
264
|
+
|
|
265
|
+
log.error?.(`[@stonyx/orm] toJSON() received an unusable \`linkage\` option -- ${reason}, so ALL relationship linkage on this \`${modelName}\` document is denied.`);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
if (linkageSupplied && !linkageUsable) {
|
|
269
|
+
denyAllLinkage(typeof linkage !== 'function'
|
|
270
|
+
? `it is of type ${linkage === null ? 'null' : typeof linkage} and it must be a function`
|
|
271
|
+
: `it is ${linkageShape} and it must be a SYNCHRONOUS function -- \`toJSON\` is the \`JSON.stringify\` hook and cannot await a verdict`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const linkageVerdict: LinkageFilter | undefined = !linkageSupplied
|
|
275
|
+
? undefined
|
|
276
|
+
: linkageUsable ? linkage as LinkageFilter : () => false;
|
|
277
|
+
|
|
278
|
+
// Applied per related record, alongside the existing `__model` liveness
|
|
279
|
+
// check, and producing exactly the shapes that check already produces: a
|
|
280
|
+
// dropped hasMany member leaves `data: []`, a dropped belongsTo leaves
|
|
281
|
+
// `data: null`. Both already ship -- a genuinely-empty hasMany emits
|
|
282
|
+
// `data: []` with links, and a cleaned belongsTo emits `data: null` -- so a
|
|
283
|
+
// filtered relationship is BYTE-IDENTICAL to an empty one and there is no
|
|
284
|
+
// new wire shape and no oracle.
|
|
285
|
+
const isLinkable = (r: Record): boolean => {
|
|
286
|
+
if (!linkageVerdict) return true;
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const verdict = linkageVerdict(r.__model.__name, r);
|
|
290
|
+
|
|
291
|
+
if (typeof verdict === 'boolean') return verdict;
|
|
292
|
+
|
|
293
|
+
denyAllLinkage(`it answered with ${describeNonVerdict(verdict)} rather than a boolean`);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
// Building the report is itself a throw site -- `throw Symbol('x')`
|
|
296
|
+
// makes `String(error)` throw, and a getter on `.message` can throw --
|
|
297
|
+
// and a throw from the reporter would escape the catch that exists so
|
|
298
|
+
// that nothing escapes.
|
|
299
|
+
let detail = 'a value that could not be described';
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
detail = error instanceof Error ? error.message : String(error);
|
|
303
|
+
} catch { /* keep the fallback -- the denial matters, the text does not */ }
|
|
304
|
+
|
|
305
|
+
denyAllLinkage(`it threw (${detail})`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return false;
|
|
309
|
+
};
|
|
310
|
+
|
|
135
311
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
136
312
|
if (fields && !fields.has(key)) continue;
|
|
137
313
|
|
|
138
314
|
const relationshipData = Array.isArray(childRecord)
|
|
139
|
-
? childRecord.map((r: Record) => ({ type: r.__model.__name, id: r.id }))
|
|
140
|
-
: childRecord ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
|
|
315
|
+
? childRecord.filter((r: Record) => r?.__model).filter(isLinkable).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
|
|
316
|
+
: (childRecord && (childRecord as Record).__model && isLinkable(childRecord as Record)) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
|
|
141
317
|
|
|
142
318
|
// Dasherize the key for URL paths (e.g., accessLinks -> access-links)
|
|
143
319
|
const dasherizedKey = camelCaseToKebabCase(key);
|
package/src/relationships.ts
CHANGED
|
@@ -51,4 +51,4 @@ export function getPendingBelongsToRegistry(): PendingBelongsToMap {
|
|
|
51
51
|
return relationships.get('pendingBelongsTo') as PendingBelongsToMap;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export const TYPES: string[] = ['global', 'hasMany', 'belongsTo', 'pending'];
|
|
54
|
+
export const TYPES: string[] = ['global', 'hasMany', 'belongsTo', 'pending', 'pendingBelongsTo'];
|
package/src/serializer.ts
CHANGED
|
@@ -94,8 +94,45 @@ export default class Serializer {
|
|
|
94
94
|
const handlerOptions = { ...options, _relationshipKey: key };
|
|
95
95
|
const childRecord = handler(record, data, handlerOptions);
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
// hasMany relationships use a getter so format()/toJSON() always read
|
|
98
|
+
// the live registry array instead of a stale snapshot captured at
|
|
99
|
+
// serialization time. This is critical when child records are created
|
|
100
|
+
// in a later async frame — the belongsTo inverse wiring pushes into
|
|
101
|
+
// the shared registry array, and the getter ensures the parent sees it.
|
|
102
|
+
const isHasMany = (handler as { __relationshipType?: string }).__relationshipType === 'hasMany';
|
|
103
|
+
|
|
104
|
+
if (isHasMany) {
|
|
105
|
+
// `childRecord` IS the shared registry array — define a getter that
|
|
106
|
+
// always dereferences through the same array reference.
|
|
107
|
+
const registryArray = childRecord as unknown[];
|
|
108
|
+
Object.defineProperty(rec, key, {
|
|
109
|
+
enumerable: true,
|
|
110
|
+
configurable: true,
|
|
111
|
+
get: () => registryArray,
|
|
112
|
+
set(v: unknown) { relatedRecords[key] = v; }
|
|
113
|
+
});
|
|
114
|
+
Object.defineProperty(relatedRecords, key, {
|
|
115
|
+
enumerable: true,
|
|
116
|
+
configurable: true,
|
|
117
|
+
get: () => registryArray,
|
|
118
|
+
set(v: unknown) { Object.defineProperty(relatedRecords, key, { value: v, writable: true, enumerable: true, configurable: true }); }
|
|
119
|
+
});
|
|
120
|
+
} else {
|
|
121
|
+
rec[key] = childRecord;
|
|
122
|
+
relatedRecords[key] = childRecord;
|
|
123
|
+
|
|
124
|
+
// Preserve the raw FK value in __data when the belongsTo handler
|
|
125
|
+
// couldn't resolve the target (e.g., memory:false model not loaded).
|
|
126
|
+
// This allows adapters to read the FK from __data as a fallback
|
|
127
|
+
// when __relationships[key] is null. Only store when `data` is a
|
|
128
|
+
// truthy non-object — i.e., a raw FK string/number that the handler
|
|
129
|
+
// attempted but failed to resolve. When `data` is null/undefined
|
|
130
|
+
// (optional empty relationship) we intentionally skip to preserve
|
|
131
|
+
// the existing behavior of not populating __data for empty FKs.
|
|
132
|
+
if (childRecord === null && data && typeof data !== 'object') {
|
|
133
|
+
parsedData[key] = data;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
99
136
|
|
|
100
137
|
continue;
|
|
101
138
|
}
|
package/src/setup-rest-server.ts
CHANGED
|
@@ -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';
|
|
@@ -7,14 +7,20 @@ import { forEachFileImport } from '@stonyx/utils/file';
|
|
|
7
7
|
import { dbKey } from './db.js';
|
|
8
8
|
import { getPluralName } from './plural-registry.js';
|
|
9
9
|
import log from 'stonyx/log';
|
|
10
|
+
import type { AccessFunction } from './types/orm-types.js';
|
|
10
11
|
|
|
11
12
|
interface AccessInstance {
|
|
12
13
|
models: string[] | '*';
|
|
13
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The consumer predicate. Called as `access(request, { model, operation })`
|
|
16
|
+
* -- the second argument is additive (abofs/stonyx-orm#202), so a predicate
|
|
17
|
+
* declared with a single parameter is still valid and still works.
|
|
18
|
+
*/
|
|
19
|
+
access: AccessFunction;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
export default async function(route: string, accessPath: string, metaRoute: boolean): Promise<void> {
|
|
17
|
-
const
|
|
23
|
+
const accessFunctions: Record<string, AccessFunction> = {};
|
|
18
24
|
|
|
19
25
|
try {
|
|
20
26
|
await forEachFileImport(accessPath, (accessClass: unknown) => {
|
|
@@ -31,9 +37,9 @@ export default async function(route: string, accessPath: string, metaRoute: bool
|
|
|
31
37
|
for (const model of models === '*' ? availableModels : models) {
|
|
32
38
|
if (model === dbKey) continue;
|
|
33
39
|
if (!store.data.has(model)) throw new Error(`Unable to define access for Invalid Model "${model}". Model does not exist`);
|
|
34
|
-
if (
|
|
40
|
+
if (accessFunctions![model]) throw new Error(`Access for model "${model}" has already been defined by another access class.`);
|
|
35
41
|
|
|
36
|
-
|
|
42
|
+
accessFunctions![model] = accessInstance.access;
|
|
37
43
|
}
|
|
38
44
|
});
|
|
39
45
|
} catch (error) {
|
|
@@ -41,13 +47,60 @@ export default async function(route: string, accessPath: string, metaRoute: bool
|
|
|
41
47
|
log.warn?.('You must define a valid access configuration file in order to access ORM generated REST endpoints.');
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
// -------------------------------------------------------------------------
|
|
51
|
+
// #202 -- the registry has to survive this function.
|
|
52
|
+
//
|
|
53
|
+
// `accessFunctions` used to be a function-local that was discarded at the return
|
|
54
|
+
// below, so the only thing that ever saw it was the mount loop. Each mounted
|
|
55
|
+
// OrmRequest then held its OWN model's predicate and nothing held the map, so
|
|
56
|
+
// at request time there was no route from a model NAME to that model's
|
|
57
|
+
// predicate -- which is what abofs/stonyx-orm#196 and #207 need in order to
|
|
58
|
+
// ask model X's predicate about a request routed to model Y.
|
|
59
|
+
//
|
|
60
|
+
// Published BEFORE `await waitForModule('rest-server')`, deliberately: that
|
|
61
|
+
// await is the ONLY yield point in this function, and the rest-server module
|
|
62
|
+
// may already be listening by the time it reports ready, so an assignment
|
|
63
|
+
// after it would leave a window in which a route is live and the registry is
|
|
64
|
+
// not.
|
|
65
|
+
//
|
|
66
|
+
// It is NOT before the mount loop for that reason, and the comment here used
|
|
67
|
+
// to say it was. `RestServer.mountRoute` is fully synchronous -- construct,
|
|
68
|
+
// registerCalls(), api.use() -- and nothing between the loop and this
|
|
69
|
+
// function's closing brace yields, so the event loop cannot deliver a request
|
|
70
|
+
// in there and the window that clause described cannot open. Measured:
|
|
71
|
+
// moving this assignment to the last statement of the function leaves the
|
|
72
|
+
// suite at 951 pass / 0 fail. Being ahead of the mount loop is free and
|
|
73
|
+
// harmless; it is not what makes the ordering correct.
|
|
74
|
+
//
|
|
75
|
+
// Assigned unconditionally, including when the try above failed and the map
|
|
76
|
+
// is empty or partial: the mount loop below is driven by this exact object,
|
|
77
|
+
// so at the moment of assignment whatever is reachable through
|
|
78
|
+
// `Orm.instance` is the same set of predicates that is about to enforce.
|
|
79
|
+
// A guard such as `if (Object.keys(accessFunctions).length)` would let the
|
|
80
|
+
// registry go silently missing on a total load failure, and a later consumer
|
|
81
|
+
// would read `undefined` from `getAccess` and have to distinguish "no access
|
|
82
|
+
// class" from "the registry was never published" -- which it cannot. That is
|
|
83
|
+
// the reasoning, and it is REASONING, not something this suite tests: the
|
|
84
|
+
// guarded variant is also 951 pass / 0 fail, AC8 included, because every boot
|
|
85
|
+
// in this suite loads a non-empty access map so the guard never fires. AC8
|
|
86
|
+
// demonstrably cannot catch it. Catching it needs a boot with
|
|
87
|
+
// `orm.paths.access` pointed at an empty directory, which this suite has no
|
|
88
|
+
// harness for.
|
|
89
|
+
//
|
|
90
|
+
// One further limit on "by construction": the mount loop passes `access` BY
|
|
91
|
+
// VALUE into each OrmRequest, so the enforcing set is a snapshot taken here,
|
|
92
|
+
// while `getAccess` reads the map live. The two are the same set at boot and
|
|
93
|
+
// stay the same set only for as long as nobody writes to the public field.
|
|
94
|
+
// The equality is a boot-time fact, not an invariant.
|
|
95
|
+
Orm.instance.accessFunctions = accessFunctions;
|
|
96
|
+
|
|
44
97
|
await waitForModule('rest-server');
|
|
45
98
|
|
|
46
99
|
// Remove "/" prefix and name mount point accordingly
|
|
47
100
|
const name = route === '/' ? 'index' : (route[0] === '/' ? route.slice(1) : route);
|
|
48
101
|
|
|
49
102
|
// Configure endpoints for models and views with access configuration
|
|
50
|
-
for (const [model, access] of Object.entries(
|
|
103
|
+
for (const [model, access] of Object.entries(accessFunctions!)) {
|
|
51
104
|
const pluralizedModel = getPluralName(model);
|
|
52
105
|
const modelName = name === 'index' ? pluralizedModel : `${name}/${pluralizedModel}`;
|
|
53
106
|
RestServer.instance.mountRoute(OrmRequest, { name: modelName, options: { model, access } });
|
package/src/standalone-db.ts
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import fs from 'fs/promises';
|
|
10
10
|
import path from 'path';
|
|
11
|
+
// `./utils.js` pulls in `@stonyx/utils/string` and nothing else -- no ORM
|
|
12
|
+
// bootstrap, no `@stonyx/orm` index, no side effects -- so the "no framework
|
|
13
|
+
// dependencies" property above still holds.
|
|
14
|
+
import { maxNumericId } from './utils.js';
|
|
11
15
|
|
|
12
16
|
interface StandaloneDBOptions {
|
|
13
17
|
dbPath?: string;
|
|
@@ -131,12 +135,19 @@ export default class StandaloneDB {
|
|
|
131
135
|
const records = await this.readCollection(collection);
|
|
132
136
|
|
|
133
137
|
if (!data.id) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
// SHARED WITH `assignRecordId` (src/manage-record.ts), which is the other
|
|
139
|
+
// place this repo picks a server-assigned id. It was a second copy of the
|
|
140
|
+
// reduce, and nothing here pointed at it — a maintainer editing this
|
|
141
|
+
// method could not discover the other existed. See `maxNumericId` for why
|
|
142
|
+
// it is not `Math.max` (abofs/stonyx-orm#203).
|
|
143
|
+
//
|
|
144
|
+
// THE TWO ARE NOT THE SAME FUNCTION beyond this line, deliberately.
|
|
145
|
+
// `StandaloneDB` has no model, id-type or transform concept, so `maxId + 1`
|
|
146
|
+
// IS its store key; `assignRecordId` has to map the candidate through the
|
|
147
|
+
// model's declared id transform first, and then walk past occupied keys.
|
|
148
|
+
// Transplanting this method's remaining logic into the ORM reproduces
|
|
149
|
+
// #203's landing-key defect exactly — which is what AC4 pins.
|
|
150
|
+
data.id = maxNumericId(records) + 1;
|
|
140
151
|
}
|
|
141
152
|
|
|
142
153
|
// Check for duplicate id
|