@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 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
- enabled: ORM_USE_REST_SERVER ?? 'true',
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 [project-structure.md](project-structure.md).
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
- const { sql, values } = this.deps.buildInsert(schema.table, insertData);
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
- const { sql, values } = this.deps.buildUpdate(schema.table, id, changedData);
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
- for (const [col, pgType] of Object.entries(schema.columns)) {
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
- // JSONB columns: stringify non-string values for PostgreSQL JSONB storage
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.112",
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.122",
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.94"
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.123",
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
- const { sql, values } = this.deps.buildInsert(schema.table, insertData);
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
- const { sql, values } = this.deps.buildUpdate(schema.table, id, changedData);
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
- for (const [col, pgType] of Object.entries(schema.columns)) {
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
- // JSONB columns: stringify non-string values for PostgreSQL JSONB storage
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