@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
package/config/environment.js
CHANGED
|
@@ -33,6 +33,9 @@ const {
|
|
|
33
33
|
TIMESCALE_DATABASE,
|
|
34
34
|
TIMESCALE_CONNECTION_LIMIT,
|
|
35
35
|
TIMESCALE_MIGRATIONS_DIR,
|
|
36
|
+
DYNAMODB_REGION,
|
|
37
|
+
DYNAMODB_ENDPOINT,
|
|
38
|
+
DYNAMODB_TABLE_PREFIX,
|
|
36
39
|
} = process.env;
|
|
37
40
|
|
|
38
41
|
export default {
|
|
@@ -84,6 +87,11 @@ export default {
|
|
|
84
87
|
migrationsDir: TIMESCALE_MIGRATIONS_DIR ?? 'migrations',
|
|
85
88
|
migrationsTable: '__migrations',
|
|
86
89
|
} : undefined,
|
|
90
|
+
dynamodb: DYNAMODB_REGION ? {
|
|
91
|
+
region: DYNAMODB_REGION,
|
|
92
|
+
endpoint: DYNAMODB_ENDPOINT || undefined,
|
|
93
|
+
tablePrefix: DYNAMODB_TABLE_PREFIX || '',
|
|
94
|
+
} : undefined,
|
|
87
95
|
restServer: {
|
|
88
96
|
enabled: ORM_USE_REST_SERVER ?? 'true', // Whether to load restServer for automatic route setup or
|
|
89
97
|
route: ORM_REST_ROUTE ?? '/',
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { AccessMethod, AccessOperation, LinkageFilter } from './types/orm-types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The classified reading of one `access()` return value.
|
|
4
|
+
*
|
|
5
|
+
* `granted: false` is a total denial. `granted: true` with no `filter` is an
|
|
6
|
+
* unconditional grant. `granted: true` WITH a filter means "grant, subject to
|
|
7
|
+
* this per-record predicate" -- the function return shape, which is the
|
|
8
|
+
* per-record hook `AccessContext` deliberately does not provide.
|
|
9
|
+
*/
|
|
10
|
+
export interface AccessVerdict {
|
|
11
|
+
granted: boolean;
|
|
12
|
+
filter?: (record: unknown) => boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Classify one `access()` return value. Extracted verbatim from `auth()`, which
|
|
16
|
+
* now calls this; the branch ORDER is load-bearing and is preserved exactly.
|
|
17
|
+
*
|
|
18
|
+
* `operation` is the verb being authorised. `undefined` -- reachable, because
|
|
19
|
+
* express delivers HEAD to the GET handler and `methodAccessMap` has no entry
|
|
20
|
+
* for it -- falls through `permitted.includes(undefined)` to a denial, which is
|
|
21
|
+
* the same answer `auth()` gave before the extraction.
|
|
22
|
+
*/
|
|
23
|
+
export declare function interpretAccess(access: AccessMethod, operation: AccessOperation | undefined): AccessVerdict;
|
|
24
|
+
/**
|
|
25
|
+
* Build a request-scoped linkage filter.
|
|
26
|
+
*
|
|
27
|
+
* TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
|
|
28
|
+
*
|
|
29
|
+
* - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
|
|
30
|
+
* which is arbitrary code with arbitrary cost and which the module has
|
|
31
|
+
* already had to guard for throwing.
|
|
32
|
+
* - one decision per `(type, id)`. `included` is deduplicated by
|
|
33
|
+
* `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
|
|
34
|
+
* per record. Measured on a bare `GET /animals` with no `include=`:
|
|
35
|
+
* 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
|
|
36
|
+
* a 6.9x reduction and 41 predicate calls saved.
|
|
37
|
+
*
|
|
38
|
+
* The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
|
|
39
|
+
* template-string composite. `Map` compares with SameValueZero, so the numeric
|
|
40
|
+
* id `1` and the string id `'1'` stay DISTINCT, where `` `${type}:${id}` `` --
|
|
41
|
+
* or a bare `String(id)` -- collapses them onto one entry and answers the second
|
|
42
|
+
* record with the first record's verdict.
|
|
43
|
+
*
|
|
44
|
+
* WHAT THAT DOES AND DOES NOT PROTECT. It cannot cross MODELS. `decisions` is
|
|
45
|
+
* already partitioned per type by `byType`, so a composite key inside a per-type
|
|
46
|
+
* map is one-to-one with the raw one and no owner's verdict could ever answer
|
|
47
|
+
* for an animal -- the claim that once stood here. The real exposure is narrower
|
|
48
|
+
* and entirely WITHIN one model: two records of the same type whose ids differ
|
|
49
|
+
* only by JavaScript type, which a per-record predicate may legitimately answer
|
|
50
|
+
* differently about (an id read off a JSON body is a string; the same id
|
|
51
|
+
* assigned by the server is a number). Pinned by unit assertion, because this
|
|
52
|
+
* fixture cannot produce the collision on its own -- `owner` ids are strings and
|
|
53
|
+
* `animal` ids are numbers.
|
|
54
|
+
*
|
|
55
|
+
* SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
|
|
56
|
+
* it -- a verdict cached across requests would answer a second caller with the
|
|
57
|
+
* first caller's authorization.
|
|
58
|
+
*/
|
|
59
|
+
export declare function createLinkageFilter(request: unknown): LinkageFilter;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared access-verdict primitive (abofs/stonyx-orm#234).
|
|
3
|
+
*
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* WHY THIS FILE EXISTS: ONE INTERPRETER, NOT TWO
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* A consumer `access()` may return six differently-shaped things -- `false`, a
|
|
8
|
+
* bare permission string, a permission array, `true`, a per-record function, or
|
|
9
|
+
* something the contract does not define at all -- and the reading of each one
|
|
10
|
+
* is a security decision. `auth()` has held that reading inline since #190.
|
|
11
|
+
* Every surface that needs to ask "may this caller see model X's record?" needs
|
|
12
|
+
* the SAME reading, or the second copy becomes an unreviewed second
|
|
13
|
+
* authorization vocabulary that answers differently about the same value.
|
|
14
|
+
*
|
|
15
|
+
* So `interpretAccess` is extracted here and `auth()` now calls it. It is the
|
|
16
|
+
* only place a return shape is classified, and abofs/stonyx-orm#232 and #233
|
|
17
|
+
* rebase onto it rather than re-deriving it.
|
|
18
|
+
*
|
|
19
|
+
* ---------------------------------------------------------------------------
|
|
20
|
+
* WHAT A LINKAGE FILTER IS, AND WHY THE CALLER BUILDS IT
|
|
21
|
+
* ---------------------------------------------------------------------------
|
|
22
|
+
* `Record.toJSON()` APPLIES a verdict; it never RESOLVES one. That is not a
|
|
23
|
+
* style choice, it is forced, and it was measured before it was decided:
|
|
24
|
+
*
|
|
25
|
+
* INPUT: origin/dev @ c5f7907, unpatched -> 967 pass / 0 fail
|
|
26
|
+
* INPUT: same + fail-closed resolution INSIDE toJSON() -> 964 pass / 3 fail
|
|
27
|
+
*
|
|
28
|
+
* and all three reds were over-denial of PERMITTED records, not the leak. Two
|
|
29
|
+
* independent reasons:
|
|
30
|
+
*
|
|
31
|
+
* 1. `toJSON()` has no request. The shipped, documented sample reads
|
|
32
|
+
* `request.path` for its `/archived` sub-path rule -- the one read of
|
|
33
|
+
* argument one the README sanctions -- and fail-closes when it is absent.
|
|
34
|
+
* Measured against the live registry:
|
|
35
|
+
*
|
|
36
|
+
* getAccess('owner')(undefined, { model:'owner', operation:'read' }) -> false
|
|
37
|
+
* getAccess('animal')(undefined,{ model:'animal', operation:'read' }) -> [Function]
|
|
38
|
+
*
|
|
39
|
+
* Same predicate object, two models, two different degradation modes,
|
|
40
|
+
* chosen by the consumer. Without a request there is no trustworthy
|
|
41
|
+
* answer to get.
|
|
42
|
+
*
|
|
43
|
+
* 2. `toJSON` is also the `JSON.stringify` hook, so `JSON.stringify({data:
|
|
44
|
+
* record})` calls `record.toJSON('data')` -- a STRING in the options slot.
|
|
45
|
+
* An implicit caller has no syntactic place to pass anything
|
|
46
|
+
* (abofs/stonyx-orm#230). The no-argument document must therefore stay
|
|
47
|
+
* byte-identical to what shipped, which also rules out fail-closed by
|
|
48
|
+
* default: `Orm.instance.accessFunctions` is `{}` in any process that
|
|
49
|
+
* never ran `setup-rest-server` (CLI, SQL-only, unit tests), so a
|
|
50
|
+
* fail-closed default would empty every relationship on every document in
|
|
51
|
+
* processes that have no REST surface to protect.
|
|
52
|
+
*
|
|
53
|
+
* The caller -- which still holds the request -- resolves the predicate,
|
|
54
|
+
* interprets it here, caches the answer, and hands `toJSON()` an already-decided
|
|
55
|
+
* `(type, record) => boolean`.
|
|
56
|
+
*/
|
|
57
|
+
import Orm from '@stonyx/orm';
|
|
58
|
+
import log from 'stonyx/log';
|
|
59
|
+
const DENIED = Object.freeze({ granted: false });
|
|
60
|
+
const GRANTED = Object.freeze({ granted: true });
|
|
61
|
+
/**
|
|
62
|
+
* Classify one `access()` return value. Extracted verbatim from `auth()`, which
|
|
63
|
+
* now calls this; the branch ORDER is load-bearing and is preserved exactly.
|
|
64
|
+
*
|
|
65
|
+
* `operation` is the verb being authorised. `undefined` -- reachable, because
|
|
66
|
+
* express delivers HEAD to the GET handler and `methodAccessMap` has no entry
|
|
67
|
+
* for it -- falls through `permitted.includes(undefined)` to a denial, which is
|
|
68
|
+
* the same answer `auth()` gave before the extraction.
|
|
69
|
+
*/
|
|
70
|
+
export function interpretAccess(access, operation) {
|
|
71
|
+
if (!access)
|
|
72
|
+
return DENIED;
|
|
73
|
+
// The function return shape IS the per-record hook. Grant the request and
|
|
74
|
+
// carry the predicate; the caller applies it per record.
|
|
75
|
+
if (typeof access === 'function')
|
|
76
|
+
return { granted: true, filter: access };
|
|
77
|
+
if (access === true)
|
|
78
|
+
return GRANTED;
|
|
79
|
+
// `AccessMethod` declares `string` legal and it fell through every branch
|
|
80
|
+
// above. A bare string is ONE permission, not a grant of all four -- reading
|
|
81
|
+
// it as a full grant is what once let `return 'read'` authorise DELETE.
|
|
82
|
+
const permitted = typeof access === 'string' ? [access] : access;
|
|
83
|
+
// Anything that is not a permission array by this point -- an object, a
|
|
84
|
+
// number, a Symbol -- is a consumer mistake, and the only safe reading of a
|
|
85
|
+
// shape the contract does not define is a denial. Fail CLOSED.
|
|
86
|
+
if (!Array.isArray(permitted))
|
|
87
|
+
return DENIED;
|
|
88
|
+
if (!permitted.includes(operation))
|
|
89
|
+
return DENIED;
|
|
90
|
+
return GRANTED;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Resolve model `type`'s verdict for a read, against the live `request`.
|
|
94
|
+
*
|
|
95
|
+
* Fails closed on both ambiguous inputs:
|
|
96
|
+
*
|
|
97
|
+
* - `getAccess(type)` -> `undefined`. That is NOT "this model is
|
|
98
|
+
* unrestricted". `setup-rest-server` catches an access-class load failure,
|
|
99
|
+
* warns, and publishes whatever PARTIAL map it had, so `undefined` covers
|
|
100
|
+
* both "no access class claims this model" and "the class that claims it
|
|
101
|
+
* failed to load" -- and the caller cannot tell them apart. Deny.
|
|
102
|
+
* - the predicate THROWS. Same reading `auth()` and `isDenied` already use:
|
|
103
|
+
* a throw is a denial, logged, never a 500 and never a grant.
|
|
104
|
+
*
|
|
105
|
+
* NOTE ON CROSS-MODEL ASKS -- READ THIS BEFORE REBASING #232 OR #233 ONTO IT.
|
|
106
|
+
* The predicate is asked about `type` while the request in hand was dispatched
|
|
107
|
+
* to a DIFFERENT model's route. This function makes another model's class
|
|
108
|
+
* REACHABLE and asks it the model-correct question (`{ model: type }`); whether
|
|
109
|
+
* the ANSWER is model-correct is the CONSUMER's, because only a predicate that
|
|
110
|
+
* READS `context.model` can give one. Since #222 this repo's fixture does. A
|
|
111
|
+
* consumer's arity-1 predicate does not, and there is no supported way to tell
|
|
112
|
+
* which kind was resolved (the boot-time arity warning is
|
|
113
|
+
* abofs/stonyx-orm#213/#221, unshipped).
|
|
114
|
+
*
|
|
115
|
+
* BOTH DEGRADATION DIRECTIONS ARE REACHABLE, AND THE SECOND ONE GRANTS. This is
|
|
116
|
+
* measured, not reasoned:
|
|
117
|
+
*
|
|
118
|
+
* - CLOSED. The migrated fixture's surviving `request.path` read means asking
|
|
119
|
+
* the OWNER predicate on a request dispatched to `GET /animals/archived`
|
|
120
|
+
* returns a bare `false` -- a whole-request deny bleeding across models,
|
|
121
|
+
* treated here as "deny this linkage", not as an error. That over-denies a
|
|
122
|
+
* PERMITTED record.
|
|
123
|
+
* - OPEN. An arity-1 predicate -- the shape `setup-rest-server.ts:15-18`
|
|
124
|
+
* still declares valid and the README calls the default in every consumer
|
|
125
|
+
* tree -- identifies its collection from the request, so asked about
|
|
126
|
+
* `owner` on a request dispatched to `/animals` it answers about ANIMALS.
|
|
127
|
+
* Measured against this repo's own fixture with `reg.owner` replaced by an
|
|
128
|
+
* arity-1 predicate that hides angela on `/owners`:
|
|
129
|
+
*
|
|
130
|
+
* GET /owners -> ["gina","michael","bob"] angela hidden, correctly
|
|
131
|
+
* GET /animals -> owners named: [angela, ...] LEAK
|
|
132
|
+
* GET /animals/1 -> owner.data {"type":"owner","id":"angela"}
|
|
133
|
+
*
|
|
134
|
+
* That is byte-for-byte the abofs/stonyx-orm#234 defect, on the surface
|
|
135
|
+
* #234 was filed for, AFTER this fix. It is not a regression -- dev
|
|
136
|
+
* published the same id unconditionally -- and this file cannot close it,
|
|
137
|
+
* because the arity signal is #213/#221. Do NOT write, here or anywhere
|
|
138
|
+
* else, that the cross-model ask degrades closed. The standing rule this
|
|
139
|
+
* paragraph is held to is in docs/project-structure.md.
|
|
140
|
+
*/
|
|
141
|
+
function resolveVerdict(request, type) {
|
|
142
|
+
const predicate = Orm.instance?.getAccess?.(type);
|
|
143
|
+
if (typeof predicate !== 'function')
|
|
144
|
+
return DENIED;
|
|
145
|
+
let access;
|
|
146
|
+
try {
|
|
147
|
+
access = predicate(request, { model: type, operation: 'read' });
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
151
|
+
return DENIED;
|
|
152
|
+
}
|
|
153
|
+
return interpretAccess(access, 'read');
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Build a request-scoped linkage filter.
|
|
157
|
+
*
|
|
158
|
+
* TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
|
|
159
|
+
*
|
|
160
|
+
* - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
|
|
161
|
+
* which is arbitrary code with arbitrary cost and which the module has
|
|
162
|
+
* already had to guard for throwing.
|
|
163
|
+
* - one decision per `(type, id)`. `included` is deduplicated by
|
|
164
|
+
* `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
|
|
165
|
+
* per record. Measured on a bare `GET /animals` with no `include=`:
|
|
166
|
+
* 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
|
|
167
|
+
* a 6.9x reduction and 41 predicate calls saved.
|
|
168
|
+
*
|
|
169
|
+
* The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
|
|
170
|
+
* template-string composite. `Map` compares with SameValueZero, so the numeric
|
|
171
|
+
* id `1` and the string id `'1'` stay DISTINCT, where `` `${type}:${id}` `` --
|
|
172
|
+
* or a bare `String(id)` -- collapses them onto one entry and answers the second
|
|
173
|
+
* record with the first record's verdict.
|
|
174
|
+
*
|
|
175
|
+
* WHAT THAT DOES AND DOES NOT PROTECT. It cannot cross MODELS. `decisions` is
|
|
176
|
+
* already partitioned per type by `byType`, so a composite key inside a per-type
|
|
177
|
+
* map is one-to-one with the raw one and no owner's verdict could ever answer
|
|
178
|
+
* for an animal -- the claim that once stood here. The real exposure is narrower
|
|
179
|
+
* and entirely WITHIN one model: two records of the same type whose ids differ
|
|
180
|
+
* only by JavaScript type, which a per-record predicate may legitimately answer
|
|
181
|
+
* differently about (an id read off a JSON body is a string; the same id
|
|
182
|
+
* assigned by the server is a number). Pinned by unit assertion, because this
|
|
183
|
+
* fixture cannot produce the collision on its own -- `owner` ids are strings and
|
|
184
|
+
* `animal` ids are numbers.
|
|
185
|
+
*
|
|
186
|
+
* SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
|
|
187
|
+
* it -- a verdict cached across requests would answer a second caller with the
|
|
188
|
+
* first caller's authorization.
|
|
189
|
+
*/
|
|
190
|
+
export function createLinkageFilter(request) {
|
|
191
|
+
const byType = new Map();
|
|
192
|
+
return function isLinkable(type, record) {
|
|
193
|
+
let entry = byType.get(type);
|
|
194
|
+
if (!entry) {
|
|
195
|
+
entry = { verdict: resolveVerdict(request, type), decisions: new Map() };
|
|
196
|
+
byType.set(type, entry);
|
|
197
|
+
}
|
|
198
|
+
const { verdict, decisions } = entry;
|
|
199
|
+
if (!verdict.granted)
|
|
200
|
+
return false;
|
|
201
|
+
if (!verdict.filter)
|
|
202
|
+
return true;
|
|
203
|
+
const id = record?.id;
|
|
204
|
+
const cached = decisions.get(id);
|
|
205
|
+
if (cached !== undefined)
|
|
206
|
+
return cached;
|
|
207
|
+
let allowed;
|
|
208
|
+
try {
|
|
209
|
+
allowed = Boolean(verdict.filter(record));
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
// A predicate that throws is a denial -- the same reading `isDenied` uses
|
|
213
|
+
// one layer down. Logged, because a predicate that throws on every record
|
|
214
|
+
// empties every relationship and, silently, that is indistinguishable
|
|
215
|
+
// from a database with no relationships in it.
|
|
216
|
+
log.error?.(`[@stonyx/orm] access filter threw while filtering linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
217
|
+
allowed = false;
|
|
218
|
+
}
|
|
219
|
+
decisions.set(id, allowed);
|
|
220
|
+
return allowed;
|
|
221
|
+
};
|
|
222
|
+
}
|
package/dist/commands.js
CHANGED
|
@@ -20,6 +20,11 @@ const commands = {
|
|
|
20
20
|
description: 'Generate a MySQL migration from current model schemas',
|
|
21
21
|
bootstrap: true,
|
|
22
22
|
run: async (args) => {
|
|
23
|
+
const config = (await import('stonyx/config')).default;
|
|
24
|
+
if (config.orm.dynamodb) {
|
|
25
|
+
console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
23
28
|
const description = args?.join(' ') || 'migration';
|
|
24
29
|
const { generateMigration } = await import('./mysql/migration-generator.js');
|
|
25
30
|
const result = await generateMigration(description);
|
|
@@ -31,12 +36,33 @@ const commands = {
|
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
},
|
|
39
|
+
'db:sync': {
|
|
40
|
+
description: 'Provision DynamoDB tables and GSIs from current model schemas',
|
|
41
|
+
bootstrap: true,
|
|
42
|
+
run: async () => {
|
|
43
|
+
const config = (await import('stonyx/config')).default;
|
|
44
|
+
if (!config.orm.dynamodb) {
|
|
45
|
+
console.error('DynamoDB is not configured. Set DYNAMODB_REGION (and optionally DYNAMODB_ENDPOINT) to enable DynamoDB mode.');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const { default: DynamoDBDB } = await import('./dynamodb/dynamodb-db.js');
|
|
49
|
+
const db = new DynamoDBDB();
|
|
50
|
+
await db.init();
|
|
51
|
+
await db.startup();
|
|
52
|
+
await db.shutdown();
|
|
53
|
+
console.log('DynamoDB tables synced successfully.');
|
|
54
|
+
}
|
|
55
|
+
},
|
|
34
56
|
'db:migrate': {
|
|
35
57
|
description: 'Apply pending MySQL migrations',
|
|
36
58
|
bootstrap: true,
|
|
37
59
|
run: async () => {
|
|
38
60
|
const config = (await import('stonyx/config')).default;
|
|
39
61
|
const mysqlConfig = config.orm.mysql;
|
|
62
|
+
if (config.orm.dynamodb) {
|
|
63
|
+
console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
40
66
|
if (!mysqlConfig) {
|
|
41
67
|
console.error('MySQL is not configured. Set MYSQL_HOST to enable MySQL mode.');
|
|
42
68
|
process.exit(1);
|
|
@@ -75,6 +101,10 @@ const commands = {
|
|
|
75
101
|
bootstrap: true,
|
|
76
102
|
run: async () => {
|
|
77
103
|
const config = (await import('stonyx/config')).default;
|
|
104
|
+
if (config.orm.dynamodb) {
|
|
105
|
+
console.log('DynamoDB does not support migration rollback. Manage table changes via the AWS console or db:sync.');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
78
108
|
const mysqlConfig = config.orm.mysql;
|
|
79
109
|
if (!mysqlConfig) {
|
|
80
110
|
console.error('MySQL is not configured. Set MYSQL_HOST to enable MySQL mode.');
|
|
@@ -113,6 +143,10 @@ const commands = {
|
|
|
113
143
|
bootstrap: true,
|
|
114
144
|
run: async () => {
|
|
115
145
|
const config = (await import('stonyx/config')).default;
|
|
146
|
+
if (config.orm.dynamodb) {
|
|
147
|
+
console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
116
150
|
const mysqlConfig = config.orm.mysql;
|
|
117
151
|
if (!mysqlConfig) {
|
|
118
152
|
console.error('MySQL is not configured. Set MYSQL_HOST to enable MySQL mode.');
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DynamoDB connection factory.
|
|
3
|
+
*
|
|
4
|
+
* Dynamically imports @aws-sdk/client-dynamodb and @aws-sdk/lib-dynamodb
|
|
5
|
+
* so these are optional peerDependencies (matching the pg/mysql2 pattern).
|
|
6
|
+
*/
|
|
7
|
+
export interface DynamoDBConfig {
|
|
8
|
+
region?: string;
|
|
9
|
+
endpoint?: string;
|
|
10
|
+
tablePrefix?: string;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
export type DocumentClient = {
|
|
14
|
+
send(command: unknown): Promise<unknown>;
|
|
15
|
+
};
|
|
16
|
+
export type DynamoDBClientConstructor = new (options: unknown) => {
|
|
17
|
+
config: unknown;
|
|
18
|
+
};
|
|
19
|
+
export type DocumentClientFromFn = {
|
|
20
|
+
from(client: unknown): DocumentClient;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Create a DynamoDBDocumentClient from the given config.
|
|
24
|
+
* Uses dynamic import so @aws-sdk/* are optional peer deps.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createDocumentClient(dbConfig: DynamoDBConfig): Promise<DocumentClient>;
|
|
27
|
+
/**
|
|
28
|
+
* Nullify the document client reference (DynamoDB connections are HTTP-based
|
|
29
|
+
* and stateless — no explicit pool close needed, but we clear the reference).
|
|
30
|
+
*/
|
|
31
|
+
export declare function destroyDocumentClient(_client: DocumentClient | null): null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DynamoDB connection factory.
|
|
3
|
+
*
|
|
4
|
+
* Dynamically imports @aws-sdk/client-dynamodb and @aws-sdk/lib-dynamodb
|
|
5
|
+
* so these are optional peerDependencies (matching the pg/mysql2 pattern).
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Create a DynamoDBDocumentClient from the given config.
|
|
9
|
+
* Uses dynamic import so @aws-sdk/* are optional peer deps.
|
|
10
|
+
*/
|
|
11
|
+
export async function createDocumentClient(dbConfig) {
|
|
12
|
+
const { DynamoDBClient } = await import('@aws-sdk/client-dynamodb');
|
|
13
|
+
const { DynamoDBDocumentClient } = await import('@aws-sdk/lib-dynamodb');
|
|
14
|
+
const clientOptions = {};
|
|
15
|
+
if (dbConfig.region)
|
|
16
|
+
clientOptions.region = dbConfig.region;
|
|
17
|
+
if (dbConfig.endpoint)
|
|
18
|
+
clientOptions.endpoint = dbConfig.endpoint;
|
|
19
|
+
const rawClient = new DynamoDBClient(clientOptions);
|
|
20
|
+
return DynamoDBDocumentClient.from(rawClient);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Nullify the document client reference (DynamoDB connections are HTTP-based
|
|
24
|
+
* and stateless — no explicit pool close needed, but we clear the reference).
|
|
25
|
+
*/
|
|
26
|
+
export function destroyDocumentClient(_client) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DynamoDB driver implementing the SqlDb PAL contract.
|
|
3
|
+
*
|
|
4
|
+
* Drop-in replacement for PostgresDB / MysqlDB — zero ORM core changes.
|
|
5
|
+
* Selected via config.orm.dynamodb.
|
|
6
|
+
*/
|
|
7
|
+
import { createDocumentClient, destroyDocumentClient } from './connection.js';
|
|
8
|
+
import type { DocumentClient, DynamoDBConfig } from './connection.js';
|
|
9
|
+
import { buildPutItem, buildGetItem, buildUpdateItem, buildDeleteItem, buildScan, buildQuery } from './operation-builder.js';
|
|
10
|
+
import { introspectModels, getTopologicalOrder } from '../postgres/schema-introspector.js';
|
|
11
|
+
import { getDynamoKeyType } from './type-map.js';
|
|
12
|
+
import { store } from '@stonyx/orm';
|
|
13
|
+
import { createRecord } from '../manage-record.js';
|
|
14
|
+
import { getPluralName } from '../plural-registry.js';
|
|
15
|
+
import config from 'stonyx/config';
|
|
16
|
+
import log from 'stonyx/log';
|
|
17
|
+
import type { OrmRecord } from '../types/orm-types.js';
|
|
18
|
+
/**
|
|
19
|
+
* Load the DynamoDB DocumentClient command constructors via dynamic import.
|
|
20
|
+
* Returns a frozen object so it can be cached in deps.
|
|
21
|
+
*/
|
|
22
|
+
export declare function loadDocClientCommands(): Promise<{
|
|
23
|
+
PutCommand: new (params: unknown) => unknown;
|
|
24
|
+
GetCommand: new (params: unknown) => unknown;
|
|
25
|
+
UpdateCommand: new (params: unknown) => unknown;
|
|
26
|
+
DeleteCommand: new (params: unknown) => unknown;
|
|
27
|
+
ScanCommand: new (params: unknown) => unknown;
|
|
28
|
+
QueryCommand: new (params: unknown) => unknown;
|
|
29
|
+
}>;
|
|
30
|
+
export declare function loadTableCommands(): Promise<{
|
|
31
|
+
DynamoDBClient: new (opts: unknown) => {
|
|
32
|
+
send(cmd: unknown): Promise<unknown>;
|
|
33
|
+
};
|
|
34
|
+
DescribeTableCommand: new (params: unknown) => unknown;
|
|
35
|
+
CreateTableCommand: new (params: unknown) => unknown;
|
|
36
|
+
UpdateTableCommand: new (params: unknown) => unknown;
|
|
37
|
+
}>;
|
|
38
|
+
interface PersistContext {
|
|
39
|
+
record?: OrmRecord;
|
|
40
|
+
recordId?: unknown;
|
|
41
|
+
oldState?: Record<string, unknown>;
|
|
42
|
+
rawData?: Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
interface PersistResponse {
|
|
45
|
+
data?: {
|
|
46
|
+
id?: unknown;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Minimal Orm module shape needed at runtime — avoids circular import at top-level. */
|
|
50
|
+
interface OrmModule {
|
|
51
|
+
default: {
|
|
52
|
+
instance: {
|
|
53
|
+
getRecordClasses(name: string): {
|
|
54
|
+
modelClass: {
|
|
55
|
+
memory?: boolean;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
isView?(name: string): boolean;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export interface DynamoDBDeps {
|
|
63
|
+
createDocumentClient: typeof createDocumentClient;
|
|
64
|
+
destroyDocumentClient: typeof destroyDocumentClient;
|
|
65
|
+
loadDocClientCommands: typeof loadDocClientCommands;
|
|
66
|
+
loadTableCommands: typeof loadTableCommands;
|
|
67
|
+
buildPutItem: typeof buildPutItem;
|
|
68
|
+
buildGetItem: typeof buildGetItem;
|
|
69
|
+
buildUpdateItem: typeof buildUpdateItem;
|
|
70
|
+
buildDeleteItem: typeof buildDeleteItem;
|
|
71
|
+
buildScan: typeof buildScan;
|
|
72
|
+
buildQuery: typeof buildQuery;
|
|
73
|
+
introspectModels: typeof introspectModels;
|
|
74
|
+
getTopologicalOrder: typeof getTopologicalOrder;
|
|
75
|
+
getDynamoKeyType: typeof getDynamoKeyType;
|
|
76
|
+
createRecord: typeof createRecord;
|
|
77
|
+
store: typeof store;
|
|
78
|
+
getPluralName: typeof getPluralName;
|
|
79
|
+
config: typeof config;
|
|
80
|
+
log: typeof log;
|
|
81
|
+
/** Injected for testing — import('@stonyx/orm') replacement */
|
|
82
|
+
_importOrm?: () => Promise<OrmModule>;
|
|
83
|
+
[key: string]: unknown;
|
|
84
|
+
}
|
|
85
|
+
export default class DynamoDBDB {
|
|
86
|
+
static instance: DynamoDBDB | undefined;
|
|
87
|
+
deps: DynamoDBDeps;
|
|
88
|
+
client: DocumentClient | null;
|
|
89
|
+
dbConfig: DynamoDBConfig;
|
|
90
|
+
/** GSI registry built during init from model introspection. */
|
|
91
|
+
private _gsiRegistry;
|
|
92
|
+
constructor(deps?: Partial<DynamoDBDeps>);
|
|
93
|
+
private requireClient;
|
|
94
|
+
private _resolveTableName;
|
|
95
|
+
/** Resolve Orm singleton — falls back to real import in production. */
|
|
96
|
+
private _getOrm;
|
|
97
|
+
init(): Promise<void>;
|
|
98
|
+
/**
|
|
99
|
+
* For each model, DescribeTable — CreateTable if missing (with GSIs, PAY_PER_REQUEST).
|
|
100
|
+
* For existing tables, check for missing GSIs and UpdateTable + poll for ACTIVE.
|
|
101
|
+
*/
|
|
102
|
+
startup(): Promise<void>;
|
|
103
|
+
shutdown(): Promise<void>;
|
|
104
|
+
/**
|
|
105
|
+
* DynamoDB does NOT use write serialization (#156).
|
|
106
|
+
*
|
|
107
|
+
* Unlike MySQL/PostgreSQL, DynamoDB has no server-side foreign key
|
|
108
|
+
* constraints and no multi-row transactions in standard single-item
|
|
109
|
+
* operations (PutItem, UpdateItem, DeleteItem). Each operation is
|
|
110
|
+
* atomic at the item level and cannot deadlock against other items.
|
|
111
|
+
* Concurrent fire-and-forget writes therefore cannot produce the
|
|
112
|
+
* cross-row lock contention that causes InnoDB/PG deadlocks.
|
|
113
|
+
*/
|
|
114
|
+
persist(operation: string, modelName: string, context: PersistContext, response: PersistResponse): Promise<void>;
|
|
115
|
+
findRecord(modelName: string, id: unknown): Promise<OrmRecord | undefined>;
|
|
116
|
+
findAll(modelName: string, conditions?: Record<string, unknown>): Promise<OrmRecord[]>;
|
|
117
|
+
loadMemoryRecords(): Promise<void>;
|
|
118
|
+
private _persistCreate;
|
|
119
|
+
private _persistUpdate;
|
|
120
|
+
private _persistDelete;
|
|
121
|
+
private _paginatedScan;
|
|
122
|
+
private _paginatedQuery;
|
|
123
|
+
/**
|
|
124
|
+
* Build the GSI registry from model introspection.
|
|
125
|
+
* Registry: modelName → attrName → gsiName
|
|
126
|
+
*
|
|
127
|
+
* FK columns (belonging to belongsTo relationships) get a GSI automatically.
|
|
128
|
+
*/
|
|
129
|
+
private _buildGsiRegistry;
|
|
130
|
+
/**
|
|
131
|
+
* Find a GSI that can serve the given conditions.
|
|
132
|
+
*/
|
|
133
|
+
private _findGsiMatch;
|
|
134
|
+
private _buildAttributeDefinitions;
|
|
135
|
+
private _buildGsiDefinitions;
|
|
136
|
+
private _waitForTableActive;
|
|
137
|
+
private _itemToRawData;
|
|
138
|
+
private _recordToItem;
|
|
139
|
+
private _evictIfNotMemory;
|
|
140
|
+
loadAllRecords(): Promise<void>;
|
|
141
|
+
}
|
|
142
|
+
export {};
|