@stonyx/orm 0.3.2-alpha.6 → 0.3.2-alpha.60
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 +580 -10
- package/config/{environment.ts → environment.js} +8 -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 +1 -0
- package/dist/main.d.ts +116 -0
- package/dist/main.js +129 -0
- package/dist/manage-record.js +34 -3
- package/dist/mysql/connection.d.ts +1 -0
- package/dist/mysql/mysql-db.d.ts +8 -0
- package/dist/mysql/mysql-db.js +44 -10
- package/dist/orm-request.d.ts +181 -3
- package/dist/orm-request.js +794 -47
- 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.js +7 -5
- package/dist/relationships.js +1 -1
- package/dist/serializer.js +38 -2
- package/dist/setup-rest-server.js +51 -5
- package/dist/store.d.ts +13 -1
- package/dist/store.js +65 -6
- package/dist/types/orm-types.d.ts +112 -0
- package/package.json +16 -7
- 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 +1 -0
- package/src/main.ts +133 -0
- package/src/manage-record.ts +41 -9
- package/src/mysql/connection.ts +1 -0
- package/src/mysql/mysql-db.ts +44 -12
- package/src/orm-request.ts +809 -50
- package/src/postgres/connection.ts +10 -6
- package/src/postgres/postgres-db.ts +44 -12
- package/src/record.ts +8 -5
- package/src/relationships.ts +1 -1
- package/src/serializer.ts +39 -2
- package/src/setup-rest-server.ts +59 -6
- package/src/store.ts +68 -6
- package/src/types/orm-types.ts +118 -0
- package/src/types/stonyx-rest-server.d.ts +14 -1
- package/src/types/stonyx.d.ts +7 -1
|
@@ -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
|
@@ -87,12 +87,15 @@ export default class Record {
|
|
|
87
87
|
|
|
88
88
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
89
89
|
if (Array.isArray(childRecord)) {
|
|
90
|
+
// Filter out cleaned records (those with no __model)
|
|
91
|
+
const live = childRecord.filter((r: Record) => r?.__model);
|
|
92
|
+
|
|
90
93
|
// Deduplicate by record ID — keep last occurrence (latest data wins)
|
|
91
94
|
const seen = new Set<unknown>();
|
|
92
95
|
const unique: Record[] = [];
|
|
93
96
|
|
|
94
|
-
for (let i =
|
|
95
|
-
const r =
|
|
97
|
+
for (let i = live.length - 1; i >= 0; i--) {
|
|
98
|
+
const r = live[i] as Record;
|
|
96
99
|
if (!seen.has(r.id)) {
|
|
97
100
|
seen.add(r.id);
|
|
98
101
|
unique.push(r);
|
|
@@ -102,7 +105,7 @@ export default class Record {
|
|
|
102
105
|
unique.reverse();
|
|
103
106
|
records[key] = unique.map((r: Record) => r.serialize());
|
|
104
107
|
} else {
|
|
105
|
-
records[key] = (childRecord as Record)?.serialize()
|
|
108
|
+
records[key] = (childRecord as Record)?.__model ? (childRecord as Record).serialize() : null;
|
|
106
109
|
}
|
|
107
110
|
}
|
|
108
111
|
|
|
@@ -136,8 +139,8 @@ export default class Record {
|
|
|
136
139
|
if (fields && !fields.has(key)) continue;
|
|
137
140
|
|
|
138
141
|
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;
|
|
142
|
+
? childRecord.filter((r: Record) => r?.__model).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
|
|
143
|
+
: (childRecord && (childRecord as Record).__model) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
|
|
141
144
|
|
|
142
145
|
// Dasherize the key for URL paths (e.g., accessLinks -> access-links)
|
|
143
146
|
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/store.ts
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
|
|
|
5
5
|
interface UnloadOptions {
|
|
@@ -64,7 +64,7 @@ export default class Store {
|
|
|
64
64
|
get(key: string): Map<number | string, unknown> | undefined;
|
|
65
65
|
get(key: string, id: number | string): unknown;
|
|
66
66
|
get(key: string, id?: number | string): Map<number | string, unknown> | unknown | undefined {
|
|
67
|
-
if (
|
|
67
|
+
if (id === undefined) return this.data.get(key);
|
|
68
68
|
|
|
69
69
|
return this.data.get(key)?.get(id);
|
|
70
70
|
}
|
|
@@ -170,14 +170,15 @@ export default class Store {
|
|
|
170
170
|
this.data.set(key, value);
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
remove(key: string, id?: number | string): void {
|
|
173
|
+
remove(key: string, id?: number | string, options?: { _skipAutoPersist?: boolean }): void {
|
|
174
174
|
// Guard: read-only views cannot have records removed
|
|
175
175
|
if (Orm.instance?.isView?.(key)) {
|
|
176
176
|
throw new Error(`Cannot remove records from read-only view '${key}'`);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
// Auto-persist delete to SQL
|
|
180
|
-
|
|
179
|
+
// Auto-persist delete to SQL (fire-and-forget) — skipped when the
|
|
180
|
+
// request path handles persist itself to avoid double-delete.
|
|
181
|
+
if (id && Orm.instance?.sqlDb && !options?._skipAutoPersist) {
|
|
181
182
|
Orm.instance.sqlDb.persist('delete', key, { recordId: id }, {}).catch((err: unknown) => {
|
|
182
183
|
Orm.instance.emitPersistError({
|
|
183
184
|
operation: 'delete',
|
|
@@ -193,6 +194,44 @@ export default class Store {
|
|
|
193
194
|
this.unloadAllRecords(key);
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Evict a record from the store with full relationship registry cleanup.
|
|
199
|
+
* The caller retains its reference to the returned record, which is the
|
|
200
|
+
* contract memory:false post-persist eviction relies on.
|
|
201
|
+
*
|
|
202
|
+
* @param registryId - The ID used when the record's relationships were
|
|
203
|
+
* registered. For SQL models with pending IDs, this is the original
|
|
204
|
+
* negative pending ID (before the adapter re-keyed to the real DB ID).
|
|
205
|
+
*/
|
|
206
|
+
evictRecord(modelName: string, id: unknown, registryId?: unknown): void {
|
|
207
|
+
const modelStore = this.data.get(modelName);
|
|
208
|
+
if (!modelStore) return;
|
|
209
|
+
|
|
210
|
+
if (typeof id !== 'string' && typeof id !== 'number') return;
|
|
211
|
+
const raw = modelStore.get(id);
|
|
212
|
+
if (!raw || !isStoreRecord(raw)) return;
|
|
213
|
+
|
|
214
|
+
const visited = new Set([`${modelName}:${id}`]);
|
|
215
|
+
|
|
216
|
+
// Remove from hasMany arrays and nullify belongsTo references using current ID
|
|
217
|
+
// (the adapter updates record.id, so value-based matches need the current ID)
|
|
218
|
+
this._removeFromHasManyArrays(modelName, id, visited);
|
|
219
|
+
this._nullifyBelongsToReferences(modelName, id, visited);
|
|
220
|
+
|
|
221
|
+
// Clean up relationship registry entries using the registry key
|
|
222
|
+
// (belongsTo/hasMany registries were keyed by the ID at registration time,
|
|
223
|
+
// which may differ from the current ID if SQL persist re-keyed the record)
|
|
224
|
+
const cleanupId = registryId ?? id;
|
|
225
|
+
this._cleanupRelationshipRegistries(modelName, cleanupId);
|
|
226
|
+
|
|
227
|
+
// If registryId differs from id, also clean with current id as safety net
|
|
228
|
+
if (registryId !== undefined && registryId !== id) {
|
|
229
|
+
this._cleanupRelationshipRegistries(modelName, id);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
modelStore.delete(id);
|
|
233
|
+
}
|
|
234
|
+
|
|
196
235
|
unloadRecord(model: string, id: unknown, options: UnloadOptions = {}): void {
|
|
197
236
|
const modelStore = this.data.get(model);
|
|
198
237
|
|
|
@@ -219,7 +258,6 @@ export default class Store {
|
|
|
219
258
|
this._removeFromHasManyArrays(modelName, recordId, visited);
|
|
220
259
|
this._nullifyBelongsToReferences(modelName, recordId, visited);
|
|
221
260
|
this._cleanupRelationshipRegistries(modelName, recordId);
|
|
222
|
-
recordToUnload.clean();
|
|
223
261
|
|
|
224
262
|
this.data.get(modelName)?.delete(recordId as string | number);
|
|
225
263
|
}
|
|
@@ -308,6 +346,30 @@ export default class Store {
|
|
|
308
346
|
|
|
309
347
|
const pendingMap = getPendingRegistry().get(modelName);
|
|
310
348
|
if (pendingMap) pendingMap.delete(recordId);
|
|
349
|
+
|
|
350
|
+
// Clean pendingBelongsTo entries in both directions
|
|
351
|
+
const pendingBelongsToMap = getPendingBelongsToRegistry();
|
|
352
|
+
if (pendingBelongsToMap) {
|
|
353
|
+
// Direction 1: evicted record was the TARGET others were waiting for
|
|
354
|
+
const targetEntries = pendingBelongsToMap.get(modelName);
|
|
355
|
+
if (targetEntries) targetEntries.delete(recordId);
|
|
356
|
+
|
|
357
|
+
// Direction 2: evicted record was the SOURCE with unresolved forward-references
|
|
358
|
+
for (const [, targetIdMap] of pendingBelongsToMap) {
|
|
359
|
+
for (const [targetId, entries] of targetIdMap) {
|
|
360
|
+
if (!Array.isArray(entries)) continue;
|
|
361
|
+
const filtered = entries.filter((e: unknown) => {
|
|
362
|
+
const entry = e as { sourceModelName?: string; relationshipId?: unknown };
|
|
363
|
+
return !(entry.sourceModelName === modelName && entry.relationshipId === recordId);
|
|
364
|
+
});
|
|
365
|
+
if (filtered.length === 0) {
|
|
366
|
+
targetIdMap.delete(targetId);
|
|
367
|
+
} else if (filtered.length < entries.length) {
|
|
368
|
+
targetIdMap.set(targetId, filtered);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
311
373
|
}
|
|
312
374
|
|
|
313
375
|
/**
|
package/src/types/orm-types.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface OrmMysqlConfig {
|
|
|
18
18
|
connectionLimit?: number;
|
|
19
19
|
migrationsDir?: string;
|
|
20
20
|
migrationsTable?: string;
|
|
21
|
+
autoMigrate?: boolean;
|
|
21
22
|
[key: string]: unknown;
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -30,6 +31,7 @@ export interface OrmPostgresConfig {
|
|
|
30
31
|
connectionLimit?: number;
|
|
31
32
|
migrationsDir?: string;
|
|
32
33
|
migrationsTable?: string;
|
|
34
|
+
autoMigrate?: boolean;
|
|
33
35
|
[key: string]: unknown;
|
|
34
36
|
}
|
|
35
37
|
|
|
@@ -48,6 +50,13 @@ export interface OrmRestServerConfig {
|
|
|
48
50
|
metaRoute: boolean;
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
export interface OrmDynamoDBConfig {
|
|
54
|
+
region?: string;
|
|
55
|
+
endpoint?: string;
|
|
56
|
+
tablePrefix?: string;
|
|
57
|
+
[key: string]: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
51
60
|
export interface OrmSection {
|
|
52
61
|
db: OrmDbConfig;
|
|
53
62
|
paths: OrmPaths;
|
|
@@ -55,6 +64,9 @@ export interface OrmSection {
|
|
|
55
64
|
mysql?: OrmMysqlConfig;
|
|
56
65
|
postgres?: OrmPostgresConfig;
|
|
57
66
|
timescale?: OrmPostgresConfig;
|
|
67
|
+
dynamodb?: OrmDynamoDBConfig;
|
|
68
|
+
logColor?: string;
|
|
69
|
+
logMethod?: string;
|
|
58
70
|
[key: string]: unknown;
|
|
59
71
|
}
|
|
60
72
|
|
|
@@ -156,3 +168,109 @@ export interface SnapshotEntry {
|
|
|
156
168
|
source?: string;
|
|
157
169
|
viewQuery?: string;
|
|
158
170
|
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The shapes a consumer `access()` predicate may return.
|
|
174
|
+
*
|
|
175
|
+
* - `false` (or any falsy value) -- deny, 403.
|
|
176
|
+
* - `true` -- allow, with no per-record filter.
|
|
177
|
+
* - a permission string or array of them, drawn from the same four verbs as
|
|
178
|
+
* {@link AccessContext.operation}. A BARE STRING IS ONE PERMISSION, not a
|
|
179
|
+
* grant of all four.
|
|
180
|
+
* - a `(record) => boolean` predicate -- allow, and filter every record the
|
|
181
|
+
* request touches through it.
|
|
182
|
+
*
|
|
183
|
+
* Anything else fails CLOSED. See `src/orm-request.ts` `auth()`.
|
|
184
|
+
*/
|
|
185
|
+
export type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The closed vocabulary `AccessContext.operation` is drawn from
|
|
189
|
+
* (abofs/stonyx-orm#202).
|
|
190
|
+
*
|
|
191
|
+
* A literal union rather than `string`, so the guarantee the prose makes is the
|
|
192
|
+
* one the compiler enforces: a consumer who writes `operation === 'GET'` or
|
|
193
|
+
* `operation === 'get'` -- the hook vocabulary, see below -- gets a compile
|
|
194
|
+
* error instead of a comparison that never matches. A predicate that stops
|
|
195
|
+
* matching falls through to the permission array, so the misreading is
|
|
196
|
+
* fail-open shaped.
|
|
197
|
+
*
|
|
198
|
+
* In-repo precedent: `PersistErrorDetail.operation` in `src/main.ts`.
|
|
199
|
+
*/
|
|
200
|
+
export type AccessOperation = 'read' | 'create' | 'update' | 'delete';
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The structural facts about the request being authorised, handed to a consumer
|
|
204
|
+
* `access()` predicate as its SECOND argument (abofs/stonyx-orm#202).
|
|
205
|
+
*
|
|
206
|
+
* These are the facts the framework already holds at authorisation time. Before
|
|
207
|
+
* #202 a consumer had to reconstruct both of them by string-matching a URL, and
|
|
208
|
+
* five independent fail-open variants of that reconstruction were found in one
|
|
209
|
+
* three-line documented example -- each one wrong in the direction that GRANTS
|
|
210
|
+
* access. Read these instead; there is nothing to parse and no variant to miss.
|
|
211
|
+
*
|
|
212
|
+
* `record` is deliberately NOT a member. `auth()` runs after route matching but
|
|
213
|
+
* before any handler executes (`@stonyx/rest-server` `src/request.ts:58-60`),
|
|
214
|
+
* so nothing has been fetched yet -- carrying a record here would force a
|
|
215
|
+
* pre-fetch on every request. It is also unnecessary: the `(record) => boolean`
|
|
216
|
+
* return shape of {@link AccessMethod} already IS the per-record hook, applied
|
|
217
|
+
* by the handlers. Auth-time and record-time are separate decision points.
|
|
218
|
+
*/
|
|
219
|
+
export interface AccessContext {
|
|
220
|
+
/**
|
|
221
|
+
* The model this route was mounted for, e.g. `'owner'` or `'phone-number'`.
|
|
222
|
+
*
|
|
223
|
+
* Model names are kebab-case, as declared under `config.orm.paths.model` and
|
|
224
|
+
* keyed in the store -- NOT the pluralised, mount-prefixed route name. It is
|
|
225
|
+
* read from the `OrmRequest` instance and is never derived from the request
|
|
226
|
+
* target, so a mount prefix, a case-varied path, a query string or an
|
|
227
|
+
* absolute-form request-target cannot change it.
|
|
228
|
+
*/
|
|
229
|
+
model: string;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The operation being authorised. Exactly one of the four {@link
|
|
233
|
+
* AccessOperation} verbs, or `undefined`. These are exactly the values of
|
|
234
|
+
* `methodAccessMap` in `src/orm-request.ts`, which is also what the
|
|
235
|
+
* permission-array return shape is matched against -- so the two forms cannot
|
|
236
|
+
* disagree.
|
|
237
|
+
*
|
|
238
|
+
* NOT the hook vocabulary. `HookContext.operation` (`src/hooks.ts`) carries
|
|
239
|
+
* `'list' | 'get' | 'create' | 'update' | 'delete'` on an identically-named
|
|
240
|
+
* key of an identically-shaped context object, and the access vocabulary
|
|
241
|
+
* collapses `list` and `get` into `'read'`. For one `GET /animals/1` a hook
|
|
242
|
+
* sees `'get'` and `access()` sees `'read'`. "No second vocabulary" is a
|
|
243
|
+
* statement about the ACCESS path only.
|
|
244
|
+
*
|
|
245
|
+
* `undefined` when the dispatched method has no entry in that map. Express
|
|
246
|
+
* delivers `HEAD` to the `GET` handler, so this is reachable. It is left
|
|
247
|
+
* undefined rather than defaulted on purpose: a fabricated `'read'` would
|
|
248
|
+
* turn an unclassified request into an authorised one.
|
|
249
|
+
*
|
|
250
|
+
* The KEY is required even though the value may be undefined: `auth()` always
|
|
251
|
+
* sets it, and a context that simply omitted it would be indistinguishable
|
|
252
|
+
* from one that classified the request and found nothing.
|
|
253
|
+
*/
|
|
254
|
+
operation: AccessOperation | undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* A consumer `access()` predicate.
|
|
259
|
+
*
|
|
260
|
+
* The second argument is ADDITIVE: JavaScript ignores extra arguments, so every
|
|
261
|
+
* pre-#202 single-argument predicate keeps working untouched. Changing the
|
|
262
|
+
* FIRST argument instead would have been the breaking form, and a predicate
|
|
263
|
+
* that can no longer identify its collection falls through to a full CRUD
|
|
264
|
+
* grant -- so the "safer" breaking change would have converted every unmigrated
|
|
265
|
+
* predicate into a fail-open.
|
|
266
|
+
*
|
|
267
|
+
* `context` is nonetheless REQUIRED in the type, and that costs back-compat
|
|
268
|
+
* nothing. TypeScript already lets a fewer-parameter implementation satisfy a
|
|
269
|
+
* more-parameter signature, so an arity-1 predicate assigns to this type
|
|
270
|
+
* cleanly -- measured under `--strict`. What the `?` bought was the opposite of
|
|
271
|
+
* safety: it silently permitted `getAccess('animal')?.(request)` at the CALL
|
|
272
|
+
* site, i.e. exactly the omission {@link AccessContext} exists to prevent, and
|
|
273
|
+
* that call gets the model-wrong answer. Required, a caller that drops the
|
|
274
|
+
* context gets `TS2554: Expected 2 arguments, but got 1`.
|
|
275
|
+
*/
|
|
276
|
+
export type AccessFunction = (request: unknown, context: AccessContext) => AccessMethod;
|
|
@@ -5,7 +5,20 @@ declare module '@stonyx/rest-server' {
|
|
|
5
5
|
|
|
6
6
|
interface RouteOptions {
|
|
7
7
|
name: string;
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* `access` is the two-argument post-#202 shape. This is the THIRD place the
|
|
10
|
+
* contract is declared (`AccessInstance.access` in
|
|
11
|
+
* `src/setup-rest-server.ts` and `OrmRequest.access` in
|
|
12
|
+
* `src/orm-request.ts` are the other two) and it is the one `mountRoute` is
|
|
13
|
+
* actually called through, at `src/setup-rest-server.ts`. It kept the
|
|
14
|
+
* pre-#202 single-argument signature after the other two migrated; the
|
|
15
|
+
* union with `Record<string, unknown>` meant nothing broke, which is
|
|
16
|
+
* exactly why it would have drifted silently.
|
|
17
|
+
*
|
|
18
|
+
* Spelled structurally rather than as `AccessFunction`: an ambient
|
|
19
|
+
* `declare module` block cannot carry an `import type`.
|
|
20
|
+
*/
|
|
21
|
+
options?: { model: string; access: (request: unknown, context: { model: string; operation: string | undefined }) => unknown } | Record<string, unknown>;
|
|
9
22
|
}
|
|
10
23
|
|
|
11
24
|
export default class RestServer {
|
package/src/types/stonyx.d.ts
CHANGED
|
@@ -5,7 +5,13 @@ declare module 'stonyx/config' {
|
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
declare module 'stonyx/log' {
|
|
8
|
-
|
|
8
|
+
interface Log {
|
|
9
|
+
db(message: string): void;
|
|
10
|
+
error(message: string, ...args: unknown[]): void;
|
|
11
|
+
defineType(type: string, setting: string, options?: Record<string, unknown> | null): void;
|
|
12
|
+
[key: string]: ((...args: unknown[]) => void) | undefined;
|
|
13
|
+
}
|
|
14
|
+
const log: Log;
|
|
9
15
|
export default log;
|
|
10
16
|
}
|
|
11
17
|
|