@stonyx/orm 0.3.2-alpha.112 → 0.3.2-alpha.113
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 +52 -2
- package/dist/column-conversion.d.ts +34 -0
- package/dist/column-conversion.js +29 -0
- package/dist/main.js +19 -1
- package/dist/postgres/column-rules.d.ts +24 -0
- package/dist/postgres/column-rules.js +25 -0
- package/dist/postgres/postgres-db.d.ts +4 -0
- package/dist/postgres/postgres-db.js +16 -9
- package/package.json +4 -4
- package/src/column-conversion.ts +48 -0
- package/src/main.ts +19 -1
- package/src/postgres/column-rules.ts +28 -0
- package/src/postgres/postgres-db.ts +20 -9
package/README.md
CHANGED
|
@@ -40,6 +40,12 @@ All properties prefixed with `__` (`__data`, `__relationships`, `__model`, `__se
|
|
|
40
40
|
npm install @stonyx/orm
|
|
41
41
|
````
|
|
42
42
|
|
|
43
|
+
That is the whole install for an ORM-only app. The database drivers and
|
|
44
|
+
`@stonyx/rest-server` are **optional peer dependencies**: a default install does
|
|
45
|
+
not put them on disk, and none of them is loaded unless your configuration asks
|
|
46
|
+
for it. Add only the ones you actually use — see
|
|
47
|
+
[Optional peer dependencies](#optional-peer-dependencies).
|
|
48
|
+
|
|
43
49
|
## Usage example
|
|
44
50
|
|
|
45
51
|
This module is part of the **Stonyx framework**. To use it, first configure the `restServer` key in your `environment.js` file:
|
|
@@ -106,7 +112,9 @@ export default {
|
|
|
106
112
|
tablePrefix: DYNAMODB_TABLE_PREFIX, // optional table name prefix
|
|
107
113
|
} : undefined,
|
|
108
114
|
restServer: {
|
|
109
|
-
|
|
115
|
+
// 'true' requires @stonyx/rest-server to be installed — see
|
|
116
|
+
// "Optional peer dependencies" below.
|
|
117
|
+
enabled: ORM_USE_REST_SERVER ?? 'false',
|
|
110
118
|
route: ORM_REST_ROUTE ?? '/'
|
|
111
119
|
}
|
|
112
120
|
}
|
|
@@ -127,6 +135,46 @@ stonyx serve
|
|
|
127
135
|
|
|
128
136
|
For further framework instructions, see the [Stonyx repository](https://github.com/abofs/stonyx).
|
|
129
137
|
|
|
138
|
+
## Optional peer dependencies
|
|
139
|
+
|
|
140
|
+
`@stonyx/orm` boots with **none** of its optional peers installed. Each one is
|
|
141
|
+
imported lazily, and only when your configuration selects it:
|
|
142
|
+
|
|
143
|
+
| Configuration | Package you must install |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `orm.restServer.enabled: 'true'` | `@stonyx/rest-server` |
|
|
146
|
+
| `orm.postgres` or `orm.timescale` | `pg` |
|
|
147
|
+
| `orm.mysql` | `mysql2` |
|
|
148
|
+
| `orm.dynamodb` | `@aws-sdk/client-dynamodb`, `@aws-sdk/lib-dynamodb` |
|
|
149
|
+
|
|
150
|
+
If a configuration key selects one that is not on disk, the failure surfaces
|
|
151
|
+
from `Orm.init()` while the framework boots:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
Cannot find package '@stonyx/rest-server' imported from .../@stonyx/orm/dist/orm-request.js
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**ORM-only, no REST server.** This is the shape the `environment.js` above is
|
|
158
|
+
written for: keep `orm.restServer.enabled` at `'false'` and install nothing
|
|
159
|
+
beyond `@stonyx/orm`. Models, relationships, serializers, transforms and hooks
|
|
160
|
+
work unchanged; only the generated REST routes are absent.
|
|
161
|
+
|
|
162
|
+
> **The module's own default is the other way round.** The `config/environment.js`
|
|
163
|
+
> that ships inside `@stonyx/orm` defaults `restServer.enabled` to `'true'`, so
|
|
164
|
+
> an app that omits the `restServer` key *entirely* gets REST switched on and
|
|
165
|
+
> needs `@stonyx/rest-server` installed. Set the key explicitly, whichever way
|
|
166
|
+
> you want it.
|
|
167
|
+
|
|
168
|
+
**Turning REST on.**
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
npm install @stonyx/rest-server
|
|
172
|
+
ORM_USE_REST_SERVER=true stonyx serve
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
See [REST Server Integration](#rest-server-integration) for access classes and
|
|
176
|
+
route configuration.
|
|
177
|
+
|
|
130
178
|
## Models
|
|
131
179
|
|
|
132
180
|
Define a model with attributes and relationships:
|
|
@@ -1009,7 +1057,9 @@ even `===` itself, so `recordId === NaN` can never be written as a guard. Treat
|
|
|
1009
1057
|
|
|
1010
1058
|
## Project Structure
|
|
1011
1059
|
|
|
1012
|
-
For a full architectural reference, see
|
|
1060
|
+
For a full architectural reference, see
|
|
1061
|
+
[docs/project-structure.md](https://github.com/abofs/stonyx-orm/blob/dev/docs/project-structure.md).
|
|
1062
|
+
That file is repo-only — it is not in the published tarball, so the link is absolute.
|
|
1013
1063
|
|
|
1014
1064
|
## License
|
|
1015
1065
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared write-path column conversion (#292).
|
|
3
|
+
*
|
|
4
|
+
* This module owns the *iteration* only — the single place where a write
|
|
5
|
+
* payload is walked column-by-column and each value is handed to the driver's
|
|
6
|
+
* own conversion rule. The rules themselves are genuinely disjoint per driver
|
|
7
|
+
* (Postgres stringifies for JSONB; DynamoDB's DocumentClient marshals objects
|
|
8
|
+
* and arrays natively and must NOT be given stringified values) and therefore
|
|
9
|
+
* deliberately live with their drivers rather than being merged here.
|
|
10
|
+
*
|
|
11
|
+
* The duplicated create/update iteration was the drift vector that produced
|
|
12
|
+
* #292: `_recordToRow` converted, `_persistUpdate` did not. Routing both
|
|
13
|
+
* through `convertRow` removes the opportunity for the two to diverge again.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* A driver-supplied per-column conversion rule.
|
|
17
|
+
*
|
|
18
|
+
* @param value the raw value about to be bound into the query
|
|
19
|
+
* @param columnType the column's declared type from `schema.columns`, or
|
|
20
|
+
* `undefined` when the column is not an attribute column
|
|
21
|
+
* (e.g. `updated_at`, FK columns and `id`, which are added
|
|
22
|
+
* outside the attribute loop). Rules MUST be a no-op for
|
|
23
|
+
* `undefined`.
|
|
24
|
+
*/
|
|
25
|
+
export type ColumnRule = (value: unknown, columnType: string | undefined) => unknown;
|
|
26
|
+
/**
|
|
27
|
+
* Applies `rule` to every column of a complete write payload.
|
|
28
|
+
*
|
|
29
|
+
* Call this on the *finished* payload immediately before handing it to the
|
|
30
|
+
* query builder — not inside a payload-building loop. Columns appended after
|
|
31
|
+
* such a loop (FKs, `updated_at`) would otherwise bypass conversion, which is
|
|
32
|
+
* the same class of defect #292 reports.
|
|
33
|
+
*/
|
|
34
|
+
export declare function convertRow(row: Record<string, unknown>, columns: Record<string, string>, rule: ColumnRule): Record<string, unknown>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared write-path column conversion (#292).
|
|
3
|
+
*
|
|
4
|
+
* This module owns the *iteration* only — the single place where a write
|
|
5
|
+
* payload is walked column-by-column and each value is handed to the driver's
|
|
6
|
+
* own conversion rule. The rules themselves are genuinely disjoint per driver
|
|
7
|
+
* (Postgres stringifies for JSONB; DynamoDB's DocumentClient marshals objects
|
|
8
|
+
* and arrays natively and must NOT be given stringified values) and therefore
|
|
9
|
+
* deliberately live with their drivers rather than being merged here.
|
|
10
|
+
*
|
|
11
|
+
* The duplicated create/update iteration was the drift vector that produced
|
|
12
|
+
* #292: `_recordToRow` converted, `_persistUpdate` did not. Routing both
|
|
13
|
+
* through `convertRow` removes the opportunity for the two to diverge again.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Applies `rule` to every column of a complete write payload.
|
|
17
|
+
*
|
|
18
|
+
* Call this on the *finished* payload immediately before handing it to the
|
|
19
|
+
* query builder — not inside a payload-building loop. Columns appended after
|
|
20
|
+
* such a loop (FKs, `updated_at`) would otherwise bypass conversion, which is
|
|
21
|
+
* the same class of defect #292 reports.
|
|
22
|
+
*/
|
|
23
|
+
export function convertRow(row, columns, rule) {
|
|
24
|
+
const converted = {};
|
|
25
|
+
for (const [col, value] of Object.entries(row)) {
|
|
26
|
+
converted[col] = rule(value, columns[col]);
|
|
27
|
+
}
|
|
28
|
+
return converted;
|
|
29
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -19,7 +19,6 @@ import log from 'stonyx/log';
|
|
|
19
19
|
import { forEachFileImport } from '@stonyx/utils/file';
|
|
20
20
|
import { kebabCaseToPascalCase, pluralize } from '@stonyx/utils/string';
|
|
21
21
|
import { registerPluralName } from './plural-registry.js';
|
|
22
|
-
import setupRestServer from './setup-rest-server.js';
|
|
23
22
|
import baseTransforms from './transforms.js';
|
|
24
23
|
import Store from './store.js';
|
|
25
24
|
import Serializer from './serializer.js';
|
|
@@ -131,6 +130,25 @@ export default class Orm {
|
|
|
131
130
|
promises.push(db.init());
|
|
132
131
|
}
|
|
133
132
|
if (restServer.enabled === 'true') {
|
|
133
|
+
// MUST stay dynamic. setup-rest-server.js names the optional
|
|
134
|
+
// '@stonyx/rest-server' peer in its own static graph — directly, and
|
|
135
|
+
// through orm-request.ts / meta-request.ts, which import `Request` at
|
|
136
|
+
// module scope because they extend it (correctly: an `extends` base
|
|
137
|
+
// class cannot be awaited). Node links a module's entire static graph
|
|
138
|
+
// before evaluating any of it, so a static import here puts that
|
|
139
|
+
// specifier on the entry graph and `import('@stonyx/orm')` throws
|
|
140
|
+
// ERR_MODULE_NOT_FOUND for an ORM-only consumer that never installed the
|
|
141
|
+
// optional peer.
|
|
142
|
+
//
|
|
143
|
+
// NOT the same reason the SQL/DynamoDB drivers above are lazy: those
|
|
144
|
+
// modules carry no static peer specifier that survives to `dist/`
|
|
145
|
+
// (postgres-db.ts:15 and mysql-db.ts:17 are `import type`, erased by
|
|
146
|
+
// tsc), so the `await import()` there is not what isolates pg / mysql2 /
|
|
147
|
+
// @aws-sdk — that happens one layer down, in src/*/connection.ts (and
|
|
148
|
+
// src/dynamodb/dynamodb-db.ts).
|
|
149
|
+
// setup-rest-server.js is the only dist module whose laziness is
|
|
150
|
+
// load-bearing for peer resolution. (#280)
|
|
151
|
+
const { default: setupRestServer } = await import('./setup-rest-server.js');
|
|
134
152
|
promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
|
|
135
153
|
}
|
|
136
154
|
// Wire up memory resolver so store.find() can check model memory flags
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ColumnRule } from '../column-conversion.js';
|
|
2
|
+
/**
|
|
3
|
+
* PostgreSQL's write-path conversion rule (#292).
|
|
4
|
+
*
|
|
5
|
+
* JSONB columns must receive a JSON *string*. node-pg's `prepareValue`
|
|
6
|
+
* (`pg/lib/utils.js`) checks `Array.isArray` before its object fallback, so a
|
|
7
|
+
* raw array is rendered as a Postgres array literal (`{"1","2","3"}`) rather
|
|
8
|
+
* than JSON. `[]` becomes `{}`, which is valid JSON, so it lands silently
|
|
9
|
+
* corrupted with no error.
|
|
10
|
+
*
|
|
11
|
+
* Two behaviours this rule must keep:
|
|
12
|
+
*
|
|
13
|
+
* - `typeof value === 'string'` passes through untouched. A JSONB column
|
|
14
|
+
* already holding a JSON string (`'{"a":1}'`) must store the object it
|
|
15
|
+
* describes; an unconditional `JSON.stringify` would double-encode it.
|
|
16
|
+
* - An `undefined` column type is a no-op. `updated_at`, `id` and FK columns
|
|
17
|
+
* are added outside the attribute loop and are absent from
|
|
18
|
+
* `schema.columns`.
|
|
19
|
+
*
|
|
20
|
+
* The `'JSONB'` sentinel is the exact literal emitted by
|
|
21
|
+
* `src/postgres/type-map.ts` (`getPgType`/`mysqlTypeToPg`), which is the only
|
|
22
|
+
* producer of the values in `schema.columns`.
|
|
23
|
+
*/
|
|
24
|
+
export declare const postgresJsonRule: ColumnRule;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL's write-path conversion rule (#292).
|
|
3
|
+
*
|
|
4
|
+
* JSONB columns must receive a JSON *string*. node-pg's `prepareValue`
|
|
5
|
+
* (`pg/lib/utils.js`) checks `Array.isArray` before its object fallback, so a
|
|
6
|
+
* raw array is rendered as a Postgres array literal (`{"1","2","3"}`) rather
|
|
7
|
+
* than JSON. `[]` becomes `{}`, which is valid JSON, so it lands silently
|
|
8
|
+
* corrupted with no error.
|
|
9
|
+
*
|
|
10
|
+
* Two behaviours this rule must keep:
|
|
11
|
+
*
|
|
12
|
+
* - `typeof value === 'string'` passes through untouched. A JSONB column
|
|
13
|
+
* already holding a JSON string (`'{"a":1}'`) must store the object it
|
|
14
|
+
* describes; an unconditional `JSON.stringify` would double-encode it.
|
|
15
|
+
* - An `undefined` column type is a no-op. `updated_at`, `id` and FK columns
|
|
16
|
+
* are added outside the attribute loop and are absent from
|
|
17
|
+
* `schema.columns`.
|
|
18
|
+
*
|
|
19
|
+
* The `'JSONB'` sentinel is the exact literal emitted by
|
|
20
|
+
* `src/postgres/type-map.ts` (`getPgType`/`mysqlTypeToPg`), which is the only
|
|
21
|
+
* producer of the values in `schema.columns`.
|
|
22
|
+
*/
|
|
23
|
+
export const postgresJsonRule = (value, columnType) => columnType === 'JSONB' && typeof value !== 'string'
|
|
24
|
+
? JSON.stringify(value)
|
|
25
|
+
: value;
|
|
@@ -8,6 +8,8 @@ import { createRecord } from '../manage-record.js';
|
|
|
8
8
|
import { confirm } from '@stonyx/utils/prompt';
|
|
9
9
|
import { readFile } from '@stonyx/utils/file';
|
|
10
10
|
import { getPluralName } from '../plural-registry.js';
|
|
11
|
+
import { convertRow } from '../column-conversion.js';
|
|
12
|
+
import { postgresJsonRule } from './column-rules.js';
|
|
11
13
|
import config from 'stonyx/config';
|
|
12
14
|
import log from 'stonyx/log';
|
|
13
15
|
import path from 'path';
|
|
@@ -57,6 +59,8 @@ interface PostgresDeps {
|
|
|
57
59
|
confirm: typeof confirm;
|
|
58
60
|
readFile: typeof readFile;
|
|
59
61
|
getPluralName: typeof getPluralName;
|
|
62
|
+
convertRow: typeof convertRow;
|
|
63
|
+
postgresJsonRule: typeof postgresJsonRule;
|
|
60
64
|
config: typeof config;
|
|
61
65
|
log: typeof log;
|
|
62
66
|
path: typeof path;
|
|
@@ -8,6 +8,8 @@ import { createRecord } from '../manage-record.js';
|
|
|
8
8
|
import { confirm } from '@stonyx/utils/prompt';
|
|
9
9
|
import { readFile } from '@stonyx/utils/file';
|
|
10
10
|
import { getPluralName } from '../plural-registry.js';
|
|
11
|
+
import { convertRow } from '../column-conversion.js';
|
|
12
|
+
import { postgresJsonRule } from './column-rules.js';
|
|
11
13
|
import { isDbError } from '../utils.js';
|
|
12
14
|
import config from 'stonyx/config';
|
|
13
15
|
import log from 'stonyx/log';
|
|
@@ -18,7 +20,8 @@ const defaultDeps = {
|
|
|
18
20
|
introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot,
|
|
19
21
|
loadLatestSnapshot, detectSchemaDrift,
|
|
20
22
|
buildInsert, buildUpdate, buildDelete, buildSelect, buildVectorSearch, buildHybridSearch,
|
|
21
|
-
createRecord, store, confirm, readFile, getPluralName, config, log, path
|
|
23
|
+
createRecord, store, confirm, readFile, getPluralName, config, log, path,
|
|
24
|
+
convertRow, postgresJsonRule
|
|
22
25
|
};
|
|
23
26
|
export default class PostgresDB {
|
|
24
27
|
/** PostgreSQL extensions to enable on pool init. Subclasses can override. */
|
|
@@ -417,7 +420,9 @@ export default class PostgresDB {
|
|
|
417
420
|
if (isPendingId) {
|
|
418
421
|
delete insertData.id;
|
|
419
422
|
}
|
|
420
|
-
|
|
423
|
+
// Convert the *complete* payload (attributes + id + FKs) in one place (#292).
|
|
424
|
+
const row = this.deps.convertRow(insertData, schema.columns, this.deps.postgresJsonRule);
|
|
425
|
+
const { sql, values } = this.deps.buildInsert(schema.table, row);
|
|
421
426
|
const result = await this.requirePool().query(sql, values);
|
|
422
427
|
// Re-key the record in the store if PostgreSQL generated the ID (via RETURNING)
|
|
423
428
|
if (isPendingId && result.rows.length > 0) {
|
|
@@ -469,7 +474,11 @@ export default class PostgresDB {
|
|
|
469
474
|
return;
|
|
470
475
|
// PostgreSQL doesn't have ON UPDATE CURRENT_TIMESTAMP -- set updated_at manually
|
|
471
476
|
changedData.updated_at = new Date();
|
|
472
|
-
|
|
477
|
+
// Same conversion, same choke point as the create path (#292). Columns
|
|
478
|
+
// appended after the attribute loop (FKs, updated_at) are absent from
|
|
479
|
+
// schema.columns and pass through untouched.
|
|
480
|
+
const row = this.deps.convertRow(changedData, schema.columns, this.deps.postgresJsonRule);
|
|
481
|
+
const { sql, values } = this.deps.buildUpdate(schema.table, id, row);
|
|
473
482
|
await this.requirePool().query(sql, values);
|
|
474
483
|
}
|
|
475
484
|
async _persistDelete(modelName, context) {
|
|
@@ -490,13 +499,11 @@ export default class PostgresDB {
|
|
|
490
499
|
if (data.id !== undefined) {
|
|
491
500
|
row.id = data.id;
|
|
492
501
|
}
|
|
493
|
-
// Attribute columns
|
|
494
|
-
|
|
502
|
+
// Attribute columns. Type conversion is NOT applied here -- it is applied
|
|
503
|
+
// once to the finished payload by the caller, via convertRow (#292).
|
|
504
|
+
for (const col of Object.keys(schema.columns)) {
|
|
495
505
|
if (data[col] !== undefined) {
|
|
496
|
-
|
|
497
|
-
row[col] = pgType === 'JSONB' && typeof data[col] !== 'string'
|
|
498
|
-
? JSON.stringify(data[col])
|
|
499
|
-
: data[col];
|
|
506
|
+
row[col] = data[col];
|
|
500
507
|
}
|
|
501
508
|
}
|
|
502
509
|
// FK columns from relationships
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"stonyx-async",
|
|
5
5
|
"stonyx-module"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.3.2-alpha.
|
|
7
|
+
"version": "0.3.2-alpha.113",
|
|
8
8
|
"description": "",
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"type": "module",
|
|
@@ -61,10 +61,10 @@
|
|
|
61
61
|
},
|
|
62
62
|
"homepage": "https://github.com/abofs/stonyx-orm#readme",
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@stonyx/cron": "0.2.1-beta.
|
|
64
|
+
"@stonyx/cron": "0.2.1-beta.146",
|
|
65
65
|
"@stonyx/events": "0.1.1-beta.64",
|
|
66
66
|
"@stonyx/utils": "0.2.3-beta.27",
|
|
67
|
-
"stonyx": "0.2.3-beta.
|
|
67
|
+
"stonyx": "0.2.3-beta.98"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"@aws-sdk/client-dynamodb": "^3.0.0",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
}
|
|
92
92
|
},
|
|
93
93
|
"devDependencies": {
|
|
94
|
-
"@stonyx/rest-server": "0.2.1-beta.
|
|
94
|
+
"@stonyx/rest-server": "0.2.1-beta.147",
|
|
95
95
|
"@types/node": "^25.6.0",
|
|
96
96
|
"mysql2": "^3.20.0",
|
|
97
97
|
"pg": "^8.20.0",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared write-path column conversion (#292).
|
|
3
|
+
*
|
|
4
|
+
* This module owns the *iteration* only — the single place where a write
|
|
5
|
+
* payload is walked column-by-column and each value is handed to the driver's
|
|
6
|
+
* own conversion rule. The rules themselves are genuinely disjoint per driver
|
|
7
|
+
* (Postgres stringifies for JSONB; DynamoDB's DocumentClient marshals objects
|
|
8
|
+
* and arrays natively and must NOT be given stringified values) and therefore
|
|
9
|
+
* deliberately live with their drivers rather than being merged here.
|
|
10
|
+
*
|
|
11
|
+
* The duplicated create/update iteration was the drift vector that produced
|
|
12
|
+
* #292: `_recordToRow` converted, `_persistUpdate` did not. Routing both
|
|
13
|
+
* through `convertRow` removes the opportunity for the two to diverge again.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A driver-supplied per-column conversion rule.
|
|
18
|
+
*
|
|
19
|
+
* @param value the raw value about to be bound into the query
|
|
20
|
+
* @param columnType the column's declared type from `schema.columns`, or
|
|
21
|
+
* `undefined` when the column is not an attribute column
|
|
22
|
+
* (e.g. `updated_at`, FK columns and `id`, which are added
|
|
23
|
+
* outside the attribute loop). Rules MUST be a no-op for
|
|
24
|
+
* `undefined`.
|
|
25
|
+
*/
|
|
26
|
+
export type ColumnRule = (value: unknown, columnType: string | undefined) => unknown;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Applies `rule` to every column of a complete write payload.
|
|
30
|
+
*
|
|
31
|
+
* Call this on the *finished* payload immediately before handing it to the
|
|
32
|
+
* query builder — not inside a payload-building loop. Columns appended after
|
|
33
|
+
* such a loop (FKs, `updated_at`) would otherwise bypass conversion, which is
|
|
34
|
+
* the same class of defect #292 reports.
|
|
35
|
+
*/
|
|
36
|
+
export function convertRow(
|
|
37
|
+
row: Record<string, unknown>,
|
|
38
|
+
columns: Record<string, string>,
|
|
39
|
+
rule: ColumnRule
|
|
40
|
+
): Record<string, unknown> {
|
|
41
|
+
const converted: Record<string, unknown> = {};
|
|
42
|
+
|
|
43
|
+
for (const [col, value] of Object.entries(row)) {
|
|
44
|
+
converted[col] = rule(value, columns[col]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return converted;
|
|
48
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -20,7 +20,6 @@ import log from 'stonyx/log';
|
|
|
20
20
|
import { forEachFileImport } from '@stonyx/utils/file';
|
|
21
21
|
import { kebabCaseToPascalCase, pluralize } from '@stonyx/utils/string';
|
|
22
22
|
import { registerPluralName } from './plural-registry.js';
|
|
23
|
-
import setupRestServer from './setup-rest-server.js';
|
|
24
23
|
import baseTransforms from './transforms.js';
|
|
25
24
|
import Store from './store.js';
|
|
26
25
|
import Serializer from './serializer.js';
|
|
@@ -177,6 +176,25 @@ export default class Orm {
|
|
|
177
176
|
}
|
|
178
177
|
|
|
179
178
|
if (restServer.enabled === 'true') {
|
|
179
|
+
// MUST stay dynamic. setup-rest-server.js names the optional
|
|
180
|
+
// '@stonyx/rest-server' peer in its own static graph — directly, and
|
|
181
|
+
// through orm-request.ts / meta-request.ts, which import `Request` at
|
|
182
|
+
// module scope because they extend it (correctly: an `extends` base
|
|
183
|
+
// class cannot be awaited). Node links a module's entire static graph
|
|
184
|
+
// before evaluating any of it, so a static import here puts that
|
|
185
|
+
// specifier on the entry graph and `import('@stonyx/orm')` throws
|
|
186
|
+
// ERR_MODULE_NOT_FOUND for an ORM-only consumer that never installed the
|
|
187
|
+
// optional peer.
|
|
188
|
+
//
|
|
189
|
+
// NOT the same reason the SQL/DynamoDB drivers above are lazy: those
|
|
190
|
+
// modules carry no static peer specifier that survives to `dist/`
|
|
191
|
+
// (postgres-db.ts:15 and mysql-db.ts:17 are `import type`, erased by
|
|
192
|
+
// tsc), so the `await import()` there is not what isolates pg / mysql2 /
|
|
193
|
+
// @aws-sdk — that happens one layer down, in src/*/connection.ts (and
|
|
194
|
+
// src/dynamodb/dynamodb-db.ts).
|
|
195
|
+
// setup-rest-server.js is the only dist module whose laziness is
|
|
196
|
+
// load-bearing for peer resolution. (#280)
|
|
197
|
+
const { default: setupRestServer } = await import('./setup-rest-server.js');
|
|
180
198
|
promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
|
|
181
199
|
}
|
|
182
200
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ColumnRule } from '../column-conversion.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PostgreSQL's write-path conversion rule (#292).
|
|
5
|
+
*
|
|
6
|
+
* JSONB columns must receive a JSON *string*. node-pg's `prepareValue`
|
|
7
|
+
* (`pg/lib/utils.js`) checks `Array.isArray` before its object fallback, so a
|
|
8
|
+
* raw array is rendered as a Postgres array literal (`{"1","2","3"}`) rather
|
|
9
|
+
* than JSON. `[]` becomes `{}`, which is valid JSON, so it lands silently
|
|
10
|
+
* corrupted with no error.
|
|
11
|
+
*
|
|
12
|
+
* Two behaviours this rule must keep:
|
|
13
|
+
*
|
|
14
|
+
* - `typeof value === 'string'` passes through untouched. A JSONB column
|
|
15
|
+
* already holding a JSON string (`'{"a":1}'`) must store the object it
|
|
16
|
+
* describes; an unconditional `JSON.stringify` would double-encode it.
|
|
17
|
+
* - An `undefined` column type is a no-op. `updated_at`, `id` and FK columns
|
|
18
|
+
* are added outside the attribute loop and are absent from
|
|
19
|
+
* `schema.columns`.
|
|
20
|
+
*
|
|
21
|
+
* The `'JSONB'` sentinel is the exact literal emitted by
|
|
22
|
+
* `src/postgres/type-map.ts` (`getPgType`/`mysqlTypeToPg`), which is the only
|
|
23
|
+
* producer of the values in `schema.columns`.
|
|
24
|
+
*/
|
|
25
|
+
export const postgresJsonRule: ColumnRule = (value, columnType) =>
|
|
26
|
+
columnType === 'JSONB' && typeof value !== 'string'
|
|
27
|
+
? JSON.stringify(value)
|
|
28
|
+
: value;
|
|
@@ -8,6 +8,8 @@ import { createRecord } from '../manage-record.js';
|
|
|
8
8
|
import { confirm } from '@stonyx/utils/prompt';
|
|
9
9
|
import { readFile } from '@stonyx/utils/file';
|
|
10
10
|
import { getPluralName } from '../plural-registry.js';
|
|
11
|
+
import { convertRow } from '../column-conversion.js';
|
|
12
|
+
import { postgresJsonRule } from './column-rules.js';
|
|
11
13
|
import { isDbError } from '../utils.js';
|
|
12
14
|
import config from 'stonyx/config';
|
|
13
15
|
import log from 'stonyx/log';
|
|
@@ -61,6 +63,8 @@ interface PostgresDeps {
|
|
|
61
63
|
confirm: typeof confirm;
|
|
62
64
|
readFile: typeof readFile;
|
|
63
65
|
getPluralName: typeof getPluralName;
|
|
66
|
+
convertRow: typeof convertRow;
|
|
67
|
+
postgresJsonRule: typeof postgresJsonRule;
|
|
64
68
|
config: typeof config;
|
|
65
69
|
log: typeof log;
|
|
66
70
|
path: typeof path;
|
|
@@ -73,7 +77,8 @@ const defaultDeps: PostgresDeps = {
|
|
|
73
77
|
introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot,
|
|
74
78
|
loadLatestSnapshot, detectSchemaDrift,
|
|
75
79
|
buildInsert, buildUpdate, buildDelete, buildSelect, buildVectorSearch, buildHybridSearch,
|
|
76
|
-
createRecord, store, confirm, readFile, getPluralName, config, log, path
|
|
80
|
+
createRecord, store, confirm, readFile, getPluralName, config, log, path,
|
|
81
|
+
convertRow, postgresJsonRule
|
|
77
82
|
};
|
|
78
83
|
|
|
79
84
|
export default class PostgresDB {
|
|
@@ -535,7 +540,10 @@ export default class PostgresDB {
|
|
|
535
540
|
delete insertData.id;
|
|
536
541
|
}
|
|
537
542
|
|
|
538
|
-
|
|
543
|
+
// Convert the *complete* payload (attributes + id + FKs) in one place (#292).
|
|
544
|
+
const row = this.deps.convertRow(insertData, schema.columns, this.deps.postgresJsonRule);
|
|
545
|
+
|
|
546
|
+
const { sql, values } = this.deps.buildInsert(schema.table, row);
|
|
539
547
|
|
|
540
548
|
const result = await this.requirePool().query(sql, values);
|
|
541
549
|
|
|
@@ -600,7 +608,12 @@ export default class PostgresDB {
|
|
|
600
608
|
// PostgreSQL doesn't have ON UPDATE CURRENT_TIMESTAMP -- set updated_at manually
|
|
601
609
|
changedData.updated_at = new Date();
|
|
602
610
|
|
|
603
|
-
|
|
611
|
+
// Same conversion, same choke point as the create path (#292). Columns
|
|
612
|
+
// appended after the attribute loop (FKs, updated_at) are absent from
|
|
613
|
+
// schema.columns and pass through untouched.
|
|
614
|
+
const row = this.deps.convertRow(changedData, schema.columns, this.deps.postgresJsonRule);
|
|
615
|
+
|
|
616
|
+
const { sql, values } = this.deps.buildUpdate(schema.table, id, row);
|
|
604
617
|
await this.requirePool().query(sql, values);
|
|
605
618
|
}
|
|
606
619
|
|
|
@@ -626,13 +639,11 @@ export default class PostgresDB {
|
|
|
626
639
|
row.id = data.id;
|
|
627
640
|
}
|
|
628
641
|
|
|
629
|
-
// Attribute columns
|
|
630
|
-
|
|
642
|
+
// Attribute columns. Type conversion is NOT applied here -- it is applied
|
|
643
|
+
// once to the finished payload by the caller, via convertRow (#292).
|
|
644
|
+
for (const col of Object.keys(schema.columns)) {
|
|
631
645
|
if (data[col] !== undefined) {
|
|
632
|
-
|
|
633
|
-
row[col] = pgType === 'JSONB' && typeof data[col] !== 'string'
|
|
634
|
-
? JSON.stringify(data[col])
|
|
635
|
-
: data[col];
|
|
646
|
+
row[col] = data[col];
|
|
636
647
|
}
|
|
637
648
|
}
|
|
638
649
|
|