@stonyx/orm 0.3.2-beta.276 → 0.3.2-beta.277

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.
@@ -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,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
+ * 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
+ // `Object.hasOwn`, not `columns[col]`: a column named `toString` or
27
+ // `constructor` would otherwise reach the rule with a function as its
28
+ // "declared type". Inert for the shipped Postgres rule (strict equality
29
+ // against 'JSONB'), but `ColumnRule` is the extension point other drivers
30
+ // write against, and the obvious `if (columnType)` rule would invert.
31
+ converted[col] = rule(value, Object.hasOwn(columns, col) ? columns[col] : undefined);
32
+ }
33
+ return converted;
34
+ }
@@ -0,0 +1,33 @@
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`, the only module that produces the values in
22
+ * `schema.columns`. The two producing functions are `getPgType` and
23
+ * `getVectorType` (`src/postgres/schema-introspector.ts` writes `columns[key]`
24
+ * at exactly three sites, all routed through those two); `mysqlTypeToPg` is not
25
+ * exported and is reachable only from inside `getPgType`.
26
+ *
27
+ * One documented gap: `getPgType` returns a consumer transform's declared
28
+ * `pgType` verbatim, so a consumer writing `pgType: 'jsonb'` puts a value in
29
+ * `schema.columns` that type-map never authored and this exact-match sentinel
30
+ * misses. It misses identically on the create path, pre-fix and post-fix --
31
+ * pre-existing and symmetric, not introduced here.
32
+ */
33
+ export declare const postgresJsonRule: ColumnRule;
@@ -0,0 +1,34 @@
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`, the only module that produces the values in
21
+ * `schema.columns`. The two producing functions are `getPgType` and
22
+ * `getVectorType` (`src/postgres/schema-introspector.ts` writes `columns[key]`
23
+ * at exactly three sites, all routed through those two); `mysqlTypeToPg` is not
24
+ * exported and is reachable only from inside `getPgType`.
25
+ *
26
+ * One documented gap: `getPgType` returns a consumer transform's declared
27
+ * `pgType` verbatim, so a consumer writing `pgType: 'jsonb'` puts a value in
28
+ * `schema.columns` that type-map never authored and this exact-match sentinel
29
+ * misses. It misses identically on the create path, pre-fix and post-fix --
30
+ * pre-existing and symmetric, not introduced here.
31
+ */
32
+ export const postgresJsonRule = (value, columnType) => columnType === 'JSONB' && typeof value !== 'string'
33
+ ? JSON.stringify(value)
34
+ : 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-beta.276",
7
+ "version": "0.3.2-beta.277",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -0,0 +1,53 @@
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
+ // `Object.hasOwn`, not `columns[col]`: a column named `toString` or
45
+ // `constructor` would otherwise reach the rule with a function as its
46
+ // "declared type". Inert for the shipped Postgres rule (strict equality
47
+ // against 'JSONB'), but `ColumnRule` is the extension point other drivers
48
+ // write against, and the obvious `if (columnType)` rule would invert.
49
+ converted[col] = rule(value, Object.hasOwn(columns, col) ? columns[col] : undefined);
50
+ }
51
+
52
+ return converted;
53
+ }
@@ -0,0 +1,37 @@
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`, the only module that produces the values in
23
+ * `schema.columns`. The two producing functions are `getPgType` and
24
+ * `getVectorType` (`src/postgres/schema-introspector.ts` writes `columns[key]`
25
+ * at exactly three sites, all routed through those two); `mysqlTypeToPg` is not
26
+ * exported and is reachable only from inside `getPgType`.
27
+ *
28
+ * One documented gap: `getPgType` returns a consumer transform's declared
29
+ * `pgType` verbatim, so a consumer writing `pgType: 'jsonb'` puts a value in
30
+ * `schema.columns` that type-map never authored and this exact-match sentinel
31
+ * misses. It misses identically on the create path, pre-fix and post-fix --
32
+ * pre-existing and symmetric, not introduced here.
33
+ */
34
+ export const postgresJsonRule: ColumnRule = (value, columnType) =>
35
+ columnType === 'JSONB' && typeof value !== 'string'
36
+ ? JSON.stringify(value)
37
+ : 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