@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.
Files changed (66) hide show
  1. package/README.md +1409 -11
  2. package/config/environment.js +99 -12
  3. package/dist/access-verdict.d.ts +85 -0
  4. package/dist/access-verdict.js +284 -0
  5. package/dist/commands.js +34 -0
  6. package/dist/dynamodb/connection.d.ts +31 -0
  7. package/dist/dynamodb/connection.js +28 -0
  8. package/dist/dynamodb/dynamodb-db.d.ts +142 -0
  9. package/dist/dynamodb/dynamodb-db.js +596 -0
  10. package/dist/dynamodb/operation-builder.d.ts +76 -0
  11. package/dist/dynamodb/operation-builder.js +116 -0
  12. package/dist/dynamodb/type-map.d.ts +31 -0
  13. package/dist/dynamodb/type-map.js +48 -0
  14. package/dist/hooks.d.ts +15 -1
  15. package/dist/index.d.ts +3 -0
  16. package/dist/index.js +8 -0
  17. package/dist/main.d.ts +116 -0
  18. package/dist/main.js +129 -0
  19. package/dist/manage-record.js +268 -12
  20. package/dist/mysql/connection.d.ts +1 -0
  21. package/dist/mysql/mysql-db.d.ts +8 -0
  22. package/dist/mysql/mysql-db.js +44 -10
  23. package/dist/orm-request.d.ts +274 -3
  24. package/dist/orm-request.js +1259 -65
  25. package/dist/postgres/connection.d.ts +1 -0
  26. package/dist/postgres/connection.js +8 -6
  27. package/dist/postgres/postgres-db.d.ts +8 -0
  28. package/dist/postgres/postgres-db.js +44 -10
  29. package/dist/record.d.ts +16 -0
  30. package/dist/record.js +154 -6
  31. package/dist/relationships.js +1 -1
  32. package/dist/serializer.js +38 -2
  33. package/dist/setup-rest-server.js +51 -5
  34. package/dist/standalone-db.js +17 -5
  35. package/dist/store.d.ts +13 -1
  36. package/dist/store.js +65 -6
  37. package/dist/types/orm-types.d.ts +260 -0
  38. package/dist/utils.d.ts +44 -0
  39. package/dist/utils.js +47 -0
  40. package/package.json +16 -7
  41. package/src/access-verdict.ts +312 -0
  42. package/src/commands.ts +43 -0
  43. package/src/dynamodb/connection.ts +50 -0
  44. package/src/dynamodb/dynamodb-db.ts +811 -0
  45. package/src/dynamodb/operation-builder.ts +202 -0
  46. package/src/dynamodb/type-map.ts +54 -0
  47. package/src/hooks.ts +15 -1
  48. package/src/index.ts +10 -0
  49. package/src/main.ts +133 -0
  50. package/src/manage-record.ts +294 -18
  51. package/src/mysql/connection.ts +1 -0
  52. package/src/mysql/mysql-db.ts +44 -12
  53. package/src/orm-request.ts +1281 -67
  54. package/src/postgres/connection.ts +10 -6
  55. package/src/postgres/postgres-db.ts +44 -12
  56. package/src/record.ts +182 -6
  57. package/src/relationships.ts +1 -1
  58. package/src/serializer.ts +39 -2
  59. package/src/setup-rest-server.ts +59 -6
  60. package/src/standalone-db.ts +17 -6
  61. package/src/store.ts +68 -6
  62. package/src/types/orm-types.ts +268 -1
  63. package/src/types/stonyx-rest-server.d.ts +14 -1
  64. package/src/types/stonyx.d.ts +7 -1
  65. package/src/utils.ts +50 -0
  66. package/config/environment.ts +0 -91
@@ -0,0 +1,312 @@
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
+ import type { AccessMethod, AccessOperation, LinkageFilter } from './types/orm-types.js';
60
+
61
+ /**
62
+ * The classified reading of one `access()` return value.
63
+ *
64
+ * `granted: false` is a total denial. `granted: true` with no `filter` is an
65
+ * unconditional grant. `granted: true` WITH a filter means "grant, subject to
66
+ * this per-record predicate" -- the function return shape, which is the
67
+ * per-record hook `AccessContext` deliberately does not provide.
68
+ */
69
+ export interface AccessVerdict {
70
+ granted: boolean;
71
+ filter?: (record: unknown) => boolean;
72
+ }
73
+
74
+ const DENIED: AccessVerdict = Object.freeze({ granted: false });
75
+ const GRANTED: AccessVerdict = Object.freeze({ granted: true });
76
+
77
+ /**
78
+ * Classify one `access()` return value. Extracted verbatim from `auth()`, which
79
+ * now calls this; the branch ORDER is load-bearing and is preserved exactly.
80
+ *
81
+ * `operation` is the verb being authorised. `undefined` -- reachable, because
82
+ * express delivers HEAD to the GET handler and `methodAccessMap` has no entry
83
+ * for it -- falls through `permitted.includes(undefined)` to a denial, which is
84
+ * the same answer `auth()` gave before the extraction.
85
+ */
86
+ export function interpretAccess(access: AccessMethod, operation: AccessOperation | undefined): AccessVerdict {
87
+ if (!access) return DENIED;
88
+
89
+ // The function return shape IS the per-record hook. Grant the request and
90
+ // carry the predicate; the caller applies it per record.
91
+ if (typeof access === 'function') return { granted: true, filter: access as (record: unknown) => boolean };
92
+
93
+ if (access === true) return GRANTED;
94
+
95
+ // `AccessMethod` declares `string` legal and it fell through every branch
96
+ // above. A bare string is ONE permission, not a grant of all four -- reading
97
+ // it as a full grant is what once let `return 'read'` authorise DELETE.
98
+ const permitted = typeof access === 'string' ? [access] : access;
99
+
100
+ // Anything that is not a permission array by this point -- an object, a
101
+ // number, a Symbol -- is a consumer mistake, and the only safe reading of a
102
+ // shape the contract does not define is a denial. Fail CLOSED.
103
+ if (!Array.isArray(permitted)) return DENIED;
104
+ if (!permitted.includes(operation as string)) return DENIED;
105
+
106
+ return GRANTED;
107
+ }
108
+
109
+ /**
110
+ * Resolve model `type`'s verdict for a read, against the live `request`.
111
+ *
112
+ * Fails closed on both ambiguous inputs:
113
+ *
114
+ * - `getAccess(type)` -> `undefined`. That is NOT "this model is
115
+ * unrestricted". `setup-rest-server` catches an access-class load failure,
116
+ * warns, and publishes whatever PARTIAL map it had, so `undefined` covers
117
+ * both "no access class claims this model" and "the class that claims it
118
+ * failed to load" -- and the caller cannot tell them apart. Deny.
119
+ * - the predicate THROWS. Same reading `auth()` and `isDenied` already use:
120
+ * a throw is a denial, logged, never a 500 and never a grant.
121
+ *
122
+ * NOTE ON CROSS-MODEL ASKS -- READ THIS BEFORE REBASING #232 OR #233 ONTO IT.
123
+ * The predicate is asked about `type` while the request in hand was dispatched
124
+ * to a DIFFERENT model's route. This function makes another model's class
125
+ * REACHABLE and asks it the model-correct question (`{ model: type }`); whether
126
+ * the ANSWER is model-correct is the CONSUMER's, because only a predicate that
127
+ * READS `context.model` can give one. Since #222 this repo's fixture does. A
128
+ * consumer's arity-1 predicate does not, and there is no supported way to tell
129
+ * which kind was resolved (the boot-time arity warning is
130
+ * abofs/stonyx-orm#213/#221, unshipped).
131
+ *
132
+ * BOTH DEGRADATION DIRECTIONS ARE REACHABLE, AND THE SECOND ONE GRANTS. This is
133
+ * measured, not reasoned:
134
+ *
135
+ * - CLOSED. The migrated fixture's surviving `request.path` read means asking
136
+ * the OWNER predicate on a request dispatched to `GET /animals/archived`
137
+ * returns a bare `false` -- a whole-request deny bleeding across models,
138
+ * treated here as "deny this linkage", not as an error. That over-denies a
139
+ * PERMITTED record.
140
+ * - OPEN. An arity-1 predicate -- the shape `setup-rest-server.ts:15-18`
141
+ * still declares valid and the README calls the default in every consumer
142
+ * tree -- identifies its collection from the request, so asked about
143
+ * `owner` on a request dispatched to `/animals` it answers about ANIMALS.
144
+ * Measured against this repo's own fixture with `reg.owner` replaced by an
145
+ * arity-1 predicate that hides angela on `/owners`:
146
+ *
147
+ * GET /owners -> ["gina","michael","bob"] angela hidden, correctly
148
+ * GET /animals -> owners named: [angela, ...] LEAK
149
+ * GET /animals/1 -> owner.data {"type":"owner","id":"angela"}
150
+ *
151
+ * That is byte-for-byte the abofs/stonyx-orm#234 defect, on the surface
152
+ * #234 was filed for, AFTER this fix. It is not a regression -- dev
153
+ * published the same id unconditionally -- and this file cannot close it,
154
+ * because the arity signal is #213/#221. Do NOT write, here or anywhere
155
+ * else, that the cross-model ask degrades closed. The standing rule this
156
+ * paragraph is held to is in docs/project-structure.md.
157
+ */
158
+ function resolveVerdict(request: unknown, type: string): AccessVerdict {
159
+ const predicate = Orm.instance?.getAccess?.(type);
160
+ if (typeof predicate !== 'function') return DENIED;
161
+
162
+ let access: AccessMethod;
163
+
164
+ try {
165
+ // `recordId: null`, AND NOT `request.params.id`. THE TEMPTING WRONG ANSWER
166
+ // IS RIGHT THERE, so this is pinned by assertion as well as by comment --
167
+ // test/unit/linkage-verdict-test.ts, `#234 + #241 -- recordId is null`.
168
+ //
169
+ // `AccessContext.recordId` (src/types/orm-types.ts, abofs/stonyx-orm#236 /
170
+ // #241) means "the record THIS ROUTE WAS ADDRESSED TO, as the store key of
171
+ // the model being authorised", and `null` means "addressed to no record".
172
+ // The id sitting on the request in hand names the PRIMARY record, which
173
+ // belongs to a DIFFERENT model -- `GET /owners/gina` carries
174
+ // `params.id === 'gina'`, and the ask being made HERE is about `animal` or
175
+ // `trait`. Filling this in from the request would hand the related model's
176
+ // predicate an id belonging to another model, which is byte-for-byte the
177
+ // cross-model confusion abofs/stonyx-orm#202 introduced this context to
178
+ // eliminate: the predicate would compare an owner's id against its own
179
+ // records and answer a question nobody asked. There is no record of THIS
180
+ // model addressed by this request, so `null` is the honest value -- the
181
+ // same spelling `auth()` uses for a collection route.
182
+ //
183
+ // NOR ANY RECORD'S OWN ID, WHICH IS THE SECOND-MOST TEMPTING ANSWER. This
184
+ // verdict is resolved ONCE PER TYPE and cached in `byType` below, before
185
+ // any record has been looked at; there is no per-record `AccessContext`
186
+ // built anywhere on this path. Seeding it from the first record of a type
187
+ // would let that record's identity answer for every later record of the
188
+ // same type -- the same "one record's verdict answers for another" defect
189
+ // the `decisions` raw-key argument below exists to prevent, just one level
190
+ // coarser. And it is unnecessary: `AccessContext` deliberately carries no
191
+ // `record` because auth-time and record-time are separate decision points,
192
+ // and the per-record point already receives the WHOLE record, id included,
193
+ // through `verdict.filter(record)`. A predicate that wants a record's id
194
+ // has the contract's own channel for it.
195
+ access = predicate(request, { model: type, operation: 'read', recordId: null });
196
+ } catch (error) {
197
+ log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
198
+
199
+ return DENIED;
200
+ }
201
+
202
+ return interpretAccess(access, 'read');
203
+ }
204
+
205
+ /**
206
+ * Build a request-scoped linkage filter.
207
+ *
208
+ * TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
209
+ *
210
+ * - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
211
+ * which is arbitrary code with arbitrary cost and which the module has
212
+ * already had to guard for throwing.
213
+ * - one decision per `(type, id)`. `included` is deduplicated by
214
+ * `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
215
+ * per record. Measured on a bare `GET /animals` with no `include=`:
216
+ * 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
217
+ * a 6.9x reduction and 41 predicate calls saved.
218
+ *
219
+ * The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
220
+ * template-string composite. `Map` compares with SameValueZero, so the numeric
221
+ * id `1` and the string id `'1'` stay DISTINCT, where `` `${type}:${id}` `` --
222
+ * or a bare `String(id)` -- collapses them onto one entry and answers the second
223
+ * record with the first record's verdict.
224
+ *
225
+ * WHAT THAT DOES AND DOES NOT PROTECT. It cannot cross MODELS. `decisions` is
226
+ * already partitioned per type by `byType`, so a composite key inside a per-type
227
+ * map is one-to-one with the raw one and no owner's verdict could ever answer
228
+ * for an animal -- the claim that once stood here. The real exposure is narrower
229
+ * and entirely WITHIN one model: two records of the same type whose ids differ
230
+ * only by JavaScript type, which a per-record predicate may legitimately answer
231
+ * differently about (an id read off a JSON body is a string; the same id
232
+ * assigned by the server is a number). Pinned by unit assertion, because this
233
+ * fixture cannot produce the collision on its own -- `owner` ids are strings and
234
+ * `animal` ids are numbers.
235
+ *
236
+ * SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
237
+ * it -- a verdict cached across requests would answer a second caller with the
238
+ * first caller's authorization.
239
+ *
240
+ * A REQUEST IS REQUIRED, AND ITS ABSENCE IS CHECKED HERE RATHER THAN DELEGATED.
241
+ * This function is EXPORTED (src/index.ts), and the README's Consumer Contracts
242
+ * section points consumers at exactly the contexts that have no live request --
243
+ * a queue payload, a websocket frame, a custom route. Without one there is no
244
+ * caller to authorise against, and this file's header already says so: the
245
+ * shipped sample reads `request.path` and fail-closes when it is absent, so
246
+ * `getAccess('owner')(undefined, ...)` is `false`, while
247
+ * `getAccess('animal')(undefined, ...)` returns a per-record predicate and
248
+ * GRANTS. Measured on this repo's own fixture before this guard existed:
249
+ *
250
+ * createLinkageFilter(undefined | null | {} | 'x' | 0)
251
+ * -> owner=false animal=TRUE trait=TRUE category=TRUE phone-number=TRUE
252
+ *
253
+ * Four of five claimed models granted, with no log, because whether an absent
254
+ * request fails closed was left ENTIRELY to consumer predicates -- and a
255
+ * predicate that ignores its request cannot fail closed on one that is missing.
256
+ * A nullish or primitive `request` therefore denies every model outright and
257
+ * says so once, at construction, so the signal exists even for a caller that
258
+ * goes on to serialize nothing.
259
+ *
260
+ * WHAT THIS CANNOT CHECK: `{}` is an object and passes. There is no request
261
+ * contract this module owns -- `auth()` reads `.method`, the shipped sample
262
+ * reads `.path`, a consumer's reads whatever it likes -- so anything past
263
+ * "is it an object" would be this module inventing a shape for someone else's
264
+ * framework. The residual is documented in the README under Consumer Contracts.
265
+ */
266
+ export function createLinkageFilter(request: unknown): LinkageFilter {
267
+ if (typeof request !== 'object' || request === null) {
268
+ log.error?.(`[@stonyx/orm] createLinkageFilter() was called with no request (received ${request === null ? 'null' : typeof request}) -- there is no caller to authorise against, so ALL relationship linkage it is asked about is denied.`);
269
+
270
+ return function isLinkable(_type: string, _record: unknown): boolean {
271
+ return false;
272
+ };
273
+ }
274
+
275
+ const byType = new Map<string, { verdict: AccessVerdict; decisions: Map<unknown, boolean> }>();
276
+
277
+ return function isLinkable(type: string, record: unknown): boolean {
278
+ let entry = byType.get(type);
279
+
280
+ if (!entry) {
281
+ entry = { verdict: resolveVerdict(request, type), decisions: new Map() };
282
+ byType.set(type, entry);
283
+ }
284
+
285
+ const { verdict, decisions } = entry;
286
+
287
+ if (!verdict.granted) return false;
288
+ if (!verdict.filter) return true;
289
+
290
+ const id = (record as { id?: unknown } | null)?.id;
291
+ const cached = decisions.get(id);
292
+ if (cached !== undefined) return cached;
293
+
294
+ let allowed: boolean;
295
+
296
+ try {
297
+ allowed = Boolean(verdict.filter(record));
298
+ } catch (error) {
299
+ // A predicate that throws is a denial -- the same reading `isDenied` uses
300
+ // one layer down. Logged, because a predicate that throws on every record
301
+ // empties every relationship and, silently, that is indistinguishable
302
+ // from a database with no relationships in it.
303
+ log.error?.(`[@stonyx/orm] access filter threw while filtering linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
304
+
305
+ allowed = false;
306
+ }
307
+
308
+ decisions.set(id, allowed);
309
+
310
+ return allowed;
311
+ };
312
+ }
package/src/commands.ts CHANGED
@@ -28,6 +28,13 @@ const commands: Record<string, Command> = {
28
28
  description: 'Generate a MySQL migration from current model schemas',
29
29
  bootstrap: true,
30
30
  run: async (args) => {
31
+ const config = (await import('stonyx/config')).default;
32
+
33
+ if (config.orm.dynamodb) {
34
+ console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
35
+ return;
36
+ }
37
+
31
38
  const description = args?.join(' ') || 'migration';
32
39
  const { generateMigration } = await import('./mysql/migration-generator.js');
33
40
  const result = await generateMigration(description);
@@ -39,6 +46,25 @@ const commands: Record<string, Command> = {
39
46
  }
40
47
  }
41
48
  },
49
+ 'db:sync': {
50
+ description: 'Provision DynamoDB tables and GSIs from current model schemas',
51
+ bootstrap: true,
52
+ run: async () => {
53
+ const config = (await import('stonyx/config')).default;
54
+
55
+ if (!config.orm.dynamodb) {
56
+ console.error('DynamoDB is not configured. Set DYNAMODB_REGION (and optionally DYNAMODB_ENDPOINT) to enable DynamoDB mode.');
57
+ process.exit(1);
58
+ }
59
+
60
+ const { default: DynamoDBDB } = await import('./dynamodb/dynamodb-db.js');
61
+ const db = new DynamoDBDB();
62
+ await db.init();
63
+ await db.startup();
64
+ await db.shutdown();
65
+ console.log('DynamoDB tables synced successfully.');
66
+ }
67
+ },
42
68
  'db:migrate': {
43
69
  description: 'Apply pending MySQL migrations',
44
70
  bootstrap: true,
@@ -46,6 +72,11 @@ const commands: Record<string, Command> = {
46
72
  const config = (await import('stonyx/config')).default;
47
73
  const mysqlConfig = config.orm.mysql;
48
74
 
75
+ if (config.orm.dynamodb) {
76
+ console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
77
+ return;
78
+ }
79
+
49
80
  if (!mysqlConfig) {
50
81
  console.error('MySQL is not configured. Set MYSQL_HOST to enable MySQL mode.');
51
82
  process.exit(1);
@@ -92,6 +123,12 @@ const commands: Record<string, Command> = {
92
123
  bootstrap: true,
93
124
  run: async () => {
94
125
  const config = (await import('stonyx/config')).default;
126
+
127
+ if (config.orm.dynamodb) {
128
+ console.log('DynamoDB does not support migration rollback. Manage table changes via the AWS console or db:sync.');
129
+ return;
130
+ }
131
+
95
132
  const mysqlConfig = config.orm.mysql;
96
133
 
97
134
  if (!mysqlConfig) {
@@ -138,6 +175,12 @@ const commands: Record<string, Command> = {
138
175
  bootstrap: true,
139
176
  run: async () => {
140
177
  const config = (await import('stonyx/config')).default;
178
+
179
+ if (config.orm.dynamodb) {
180
+ console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
181
+ return;
182
+ }
183
+
141
184
  const mysqlConfig = config.orm.mysql;
142
185
 
143
186
  if (!mysqlConfig) {
@@ -0,0 +1,50 @@
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
+ export interface DynamoDBConfig {
9
+ region?: string;
10
+ endpoint?: string;
11
+ tablePrefix?: string;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ // Type aliases — declared loose so we don't need to import the real SDK types
16
+ // at compile time (they're optional peer deps).
17
+ export type DocumentClient = {
18
+ send(command: unknown): Promise<unknown>;
19
+ };
20
+
21
+ export type DynamoDBClientConstructor = new (options: unknown) => { config: unknown };
22
+ export type DocumentClientFromFn = { from(client: unknown): DocumentClient };
23
+
24
+ /**
25
+ * Create a DynamoDBDocumentClient from the given config.
26
+ * Uses dynamic import so @aws-sdk/* are optional peer deps.
27
+ */
28
+ export async function createDocumentClient(dbConfig: DynamoDBConfig): Promise<DocumentClient> {
29
+ const { DynamoDBClient } = await import('@aws-sdk/client-dynamodb' as string) as {
30
+ DynamoDBClient: DynamoDBClientConstructor;
31
+ };
32
+ const { DynamoDBDocumentClient } = await import('@aws-sdk/lib-dynamodb' as string) as {
33
+ DynamoDBDocumentClient: DocumentClientFromFn;
34
+ };
35
+
36
+ const clientOptions: Record<string, unknown> = {};
37
+ if (dbConfig.region) clientOptions.region = dbConfig.region;
38
+ if (dbConfig.endpoint) clientOptions.endpoint = dbConfig.endpoint;
39
+
40
+ const rawClient = new DynamoDBClient(clientOptions);
41
+ return DynamoDBDocumentClient.from(rawClient);
42
+ }
43
+
44
+ /**
45
+ * Nullify the document client reference (DynamoDB connections are HTTP-based
46
+ * and stateless — no explicit pool close needed, but we clear the reference).
47
+ */
48
+ export function destroyDocumentClient(_client: DocumentClient | null): null {
49
+ return null;
50
+ }