@owlmeans/postgres-resource 0.1.18-rc.42 → 0.1.18-rc.43

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 (154) hide show
  1. package/README.md +13 -13
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/postgres-resource/SKILL.md +1 -1
  4. package/build/consts.d.ts +9 -0
  5. package/build/consts.d.ts.map +1 -1
  6. package/build/consts.js +6 -0
  7. package/build/consts.js.map +1 -1
  8. package/build/declarations/types.d.ts +12 -0
  9. package/build/declarations/types.d.ts.map +1 -0
  10. package/build/declarations/types.js +2 -0
  11. package/build/declarations/types.js.map +1 -0
  12. package/build/declarations.d.ts +5 -1
  13. package/build/declarations.d.ts.map +1 -1
  14. package/build/declarations.js +25 -15
  15. package/build/declarations.js.map +1 -1
  16. package/build/errors.d.ts +0 -20
  17. package/build/errors.d.ts.map +1 -1
  18. package/build/errors.js +1 -111
  19. package/build/errors.js.map +1 -1
  20. package/build/index.d.ts +3 -0
  21. package/build/index.d.ts.map +1 -1
  22. package/build/index.js +1 -0
  23. package/build/index.js.map +1 -1
  24. package/build/pg-error/types.d.ts +24 -0
  25. package/build/pg-error/types.d.ts.map +1 -0
  26. package/build/pg-error/types.js +2 -0
  27. package/build/pg-error/types.js.map +1 -0
  28. package/build/pg-error.d.ts +4 -0
  29. package/build/pg-error.d.ts.map +1 -0
  30. package/build/pg-error.js +100 -0
  31. package/build/pg-error.js.map +1 -0
  32. package/build/resource.d.ts.map +1 -1
  33. package/build/resource.js +15 -11
  34. package/build/resource.js.map +1 -1
  35. package/build/types.d.ts.map +1 -1
  36. package/build/utils/consts.local.d.ts +2 -0
  37. package/build/utils/consts.local.d.ts.map +1 -1
  38. package/build/utils/consts.local.js +2 -0
  39. package/build/utils/consts.local.js.map +1 -1
  40. package/build/utils/criteria/types.d.ts +31 -0
  41. package/build/utils/criteria/types.d.ts.map +1 -0
  42. package/build/utils/criteria/types.js +2 -0
  43. package/build/utils/criteria/types.js.map +1 -0
  44. package/build/utils/criteria.d.ts +3 -27
  45. package/build/utils/criteria.d.ts.map +1 -1
  46. package/build/utils/criteria.js +86 -104
  47. package/build/utils/criteria.js.map +1 -1
  48. package/build/utils/diff/types.d.ts +40 -0
  49. package/build/utils/diff/types.d.ts.map +1 -0
  50. package/build/utils/diff/types.js +2 -0
  51. package/build/utils/diff/types.js.map +1 -0
  52. package/build/utils/diff.d.ts +5 -35
  53. package/build/utils/diff.d.ts.map +1 -1
  54. package/build/utils/diff.js +320 -344
  55. package/build/utils/diff.js.map +1 -1
  56. package/build/utils/index.d.ts +9 -0
  57. package/build/utils/index.d.ts.map +1 -1
  58. package/build/utils/index.js.map +1 -1
  59. package/build/utils/introspect/types.d.ts +9 -0
  60. package/build/utils/introspect/types.d.ts.map +1 -0
  61. package/build/utils/introspect/types.js +2 -0
  62. package/build/utils/introspect/types.js.map +1 -0
  63. package/build/utils/introspect.d.ts +3 -3
  64. package/build/utils/introspect.d.ts.map +1 -1
  65. package/build/utils/introspect.js +41 -38
  66. package/build/utils/introspect.js.map +1 -1
  67. package/build/utils/life-cycle/types.d.ts +37 -0
  68. package/build/utils/life-cycle/types.d.ts.map +1 -0
  69. package/build/utils/life-cycle/types.js +2 -0
  70. package/build/utils/life-cycle/types.js.map +1 -0
  71. package/build/utils/life-cycle.d.ts +3 -33
  72. package/build/utils/life-cycle.d.ts.map +1 -1
  73. package/build/utils/life-cycle.js +88 -108
  74. package/build/utils/life-cycle.js.map +1 -1
  75. package/build/utils/marshal/types.d.ts +39 -0
  76. package/build/utils/marshal/types.d.ts.map +1 -0
  77. package/build/utils/marshal/types.js +2 -0
  78. package/build/utils/marshal/types.js.map +1 -0
  79. package/build/utils/marshal.d.ts +4 -32
  80. package/build/utils/marshal.d.ts.map +1 -1
  81. package/build/utils/marshal.js +121 -144
  82. package/build/utils/marshal.js.map +1 -1
  83. package/build/utils/migrations.d.ts.map +1 -1
  84. package/build/utils/migrations.js +41 -36
  85. package/build/utils/migrations.js.map +1 -1
  86. package/build/utils/name/types.d.ts +43 -0
  87. package/build/utils/name/types.d.ts.map +1 -0
  88. package/build/utils/name/types.js +2 -0
  89. package/build/utils/name/types.js.map +1 -0
  90. package/build/utils/name.d.ts +6 -35
  91. package/build/utils/name.d.ts.map +1 -1
  92. package/build/utils/name.js +38 -57
  93. package/build/utils/name.js.map +1 -1
  94. package/build/utils/schema/types.d.ts +22 -0
  95. package/build/utils/schema/types.d.ts.map +1 -0
  96. package/build/utils/schema/types.js +2 -0
  97. package/build/utils/schema/types.js.map +1 -0
  98. package/build/utils/schema.d.ts +4 -24
  99. package/build/utils/schema.d.ts.map +1 -1
  100. package/build/utils/schema.js +339 -352
  101. package/build/utils/schema.js.map +1 -1
  102. package/build/utils/sql/types.d.ts +25 -0
  103. package/build/utils/sql/types.d.ts.map +1 -0
  104. package/build/utils/sql/types.js +2 -0
  105. package/build/utils/sql/types.js.map +1 -0
  106. package/build/utils/sql.d.ts +4 -18
  107. package/build/utils/sql.d.ts.map +1 -1
  108. package/build/utils/sql.js +0 -0
  109. package/build/utils/sql.js.map +1 -1
  110. package/build/utils/sync/types.d.ts +25 -0
  111. package/build/utils/sync/types.d.ts.map +1 -0
  112. package/build/utils/sync/types.js +2 -0
  113. package/build/utils/sync/types.js.map +1 -0
  114. package/build/utils/sync.d.ts +2 -19
  115. package/build/utils/sync.d.ts.map +1 -1
  116. package/build/utils/sync.js +64 -71
  117. package/build/utils/sync.js.map +1 -1
  118. package/package.json +7 -7
  119. package/src/consts.ts +7 -0
  120. package/src/declarations/types.ts +12 -0
  121. package/src/declarations.ts +29 -14
  122. package/src/errors.ts +1 -121
  123. package/src/index.ts +3 -0
  124. package/src/pg-error/types.ts +23 -0
  125. package/src/pg-error.ts +114 -0
  126. package/src/resource.ts +17 -13
  127. package/src/types.ts +1 -0
  128. package/src/utils/consts.local.ts +3 -0
  129. package/src/utils/criteria/types.ts +32 -0
  130. package/src/utils/criteria.ts +95 -110
  131. package/src/utils/diff/types.ts +41 -0
  132. package/src/utils/diff.ts +330 -348
  133. package/src/utils/index.ts +9 -0
  134. package/src/utils/introspect/types.ts +9 -0
  135. package/src/utils/introspect.ts +51 -45
  136. package/src/utils/life-cycle/types.ts +41 -0
  137. package/src/utils/life-cycle.ts +102 -121
  138. package/src/utils/marshal/types.ts +40 -0
  139. package/src/utils/marshal.ts +129 -147
  140. package/src/utils/migrations.ts +39 -33
  141. package/src/utils/name/types.ts +44 -0
  142. package/src/utils/name.ts +47 -60
  143. package/src/utils/schema/types.ts +26 -0
  144. package/src/utils/schema.ts +359 -366
  145. package/src/utils/sql/types.ts +25 -0
  146. package/src/utils/sql.ts +0 -0
  147. package/src/utils/sync/types.ts +25 -0
  148. package/src/utils/sync.ts +62 -68
  149. package/tests/criteria.spec.ts +4 -1
  150. package/tests/diff.spec.ts +4 -1
  151. package/tests/errors.spec.ts +4 -3
  152. package/tests/name.spec.ts +5 -3
  153. package/tests/schema.spec.ts +3 -1
  154. package/tests/sql.spec.ts +20 -22
@@ -1,382 +1,369 @@
1
1
  import { UnsupportedArgumentError } from '@owlmeans/resource';
2
2
  import { logger } from '@owlmeans/log';
3
3
  import { DEF_ID_DEFAULT, DEF_JSON_TYPE, DEF_SQL_TYPE, ID_FIELD, PG_KEYWORD, PgIndexMethod } from '../consts.js';
4
- import { pgIdentifier, qualify, quoteIdent } from './name.js';
4
+ import { pgNameHelper } from './name.js';
5
+ import { INT32_MAX } from './consts.local.js';
5
6
  const log = logger('postgres-resource');
6
- const INT32_MAX = 2147483647;
7
- /**
8
- * Normalize a Postgres type name to the spelling `format_type()` reports, so the drift
9
- * comparison against a live table is an exact string match instead of a fuzzy one.
10
- */
11
- export const toFormatType = (type) => {
12
- const trimmed = type.trim().toLowerCase();
13
- const match = /^([a-z0-9_ ]+?)\s*(\(([^)]*)\))?\s*(\[\])?$/.exec(trimmed);
14
- if (match == null) {
15
- return trimmed;
16
- }
17
- const [, base, , args, array] = match;
18
- const aliases = {
19
- int: 'integer', int4: 'integer', int2: 'smallint', int8: 'bigint', serial: 'integer',
20
- bool: 'boolean', float4: 'real', float8: 'double precision', decimal: 'numeric',
21
- varchar: 'character varying', char: 'character', bpchar: 'character',
22
- timestamptz: 'timestamp with time zone', timetz: 'time with time zone',
23
- timestamp: 'timestamp without time zone', time: 'time without time zone'
24
- };
25
- const resolved = aliases[base.trim()] ?? base.trim();
26
- /** Postgres prints multi-argument typmods without spaces: `numeric(12,2)`. */
27
- const suffix = args != null ? `(${args.split(',').map(part => part.trim()).join(',')})` : '';
28
- return `${resolved}${suffix}${array ?? ''}`;
29
- };
30
- const readOverride = (property) => property[PG_KEYWORD] ?? {};
31
- /**
32
- * Nullability is decided in exactly one place and threaded down from there. Mongo's mapper
33
- * recomputes it per branch and lost it twice — on `date-time` and on optional objects.
34
- */
35
- const isNullable = (property, isRequired) => {
36
- const override = readOverride(property);
37
- if (override.nullable != null) {
38
- return override.nullable;
39
- }
40
- if (property.nullable === true) {
41
- return true;
42
- }
43
- if (Array.isArray(property.type) && property.type.includes('null')) {
44
- return true;
45
- }
46
- return !isRequired;
47
- };
48
- const baseType = (property) => Array.isArray(property.type) ? property.type.find(type => type !== 'null') : property.type;
49
- const scalarSqlType = (property) => {
50
- const override = readOverride(property);
51
- const type = baseType(property);
52
- switch (type) {
53
- case 'string':
54
- switch (property.format) {
55
- case 'date-time': return { sqlType: 'timestamp with time zone', jsonType: 'date' };
56
- case 'date': return { sqlType: 'date', jsonType: 'date' };
57
- case 'time': return { sqlType: 'time without time zone', jsonType: 'string' };
58
- case 'uuid': return { sqlType: 'uuid', jsonType: 'string' };
59
- case 'binary': return { sqlType: 'bytea', jsonType: 'binary' };
60
- default: break;
61
- }
62
- return property.maxLength != null
63
- ? { sqlType: `character varying(${property.maxLength})`, jsonType: 'string' }
64
- : { sqlType: DEF_SQL_TYPE, jsonType: 'string' };
65
- case 'integer':
66
- return property.maximum != null && property.maximum > INT32_MAX
67
- ? { sqlType: 'bigint', jsonType: 'bigint' }
68
- : { sqlType: 'integer', jsonType: 'integer' };
69
- case 'number':
70
- return override.precision != null
71
- ? { sqlType: `numeric(${override.precision},${override.scale ?? 0})`, jsonType: 'number' }
72
- : { sqlType: 'double precision', jsonType: 'number' };
73
- case 'boolean':
74
- return { sqlType: 'boolean', jsonType: 'boolean' };
75
- case 'object':
76
- /**
77
- * The framework's date convention — `{ type: 'object', format: 'date-time' }`. Mongo's
78
- * mapper broke twice on exactly this branch by dropping nullability, so nullability is
79
- * decided by the caller, never here.
80
- */
81
- return property.format === 'date-time'
82
- ? { sqlType: 'timestamp with time zone', jsonType: 'date' }
83
- : { sqlType: DEF_JSON_TYPE, jsonType: 'object' };
84
- default:
85
- return { sqlType: DEF_JSON_TYPE, jsonType: 'unknown' };
86
- }
87
- };
88
- const arraySqlType = (property) => {
89
- const items = property.items;
90
- const itemType = items != null ? baseType(items) : undefined;
91
- /** Only scalar element types become Postgres arrays; anything else stays jsonb. */
92
- if (items != null && itemType != null && ['string', 'integer', 'number', 'boolean'].includes(itemType)
93
- && items.format == null && items.enum == null) {
94
- const scalar = scalarSqlType(items);
95
- return { sqlType: `${scalar.sqlType}[]`, jsonType: 'array', array: true };
96
- }
97
- return { sqlType: DEF_JSON_TYPE, jsonType: 'array', array: false };
98
- };
99
- const compileColumn = (name, property, required) => {
100
- const override = readOverride(property);
101
- const secure = property.secure === true;
102
- const type = baseType(property);
103
- const nullable = isNullable(property, required.includes(name));
104
- let sqlType;
105
- let jsonType;
106
- let array = false;
107
- if (override.type != null) {
108
- sqlType = override.type;
109
- jsonType = secure ? 'string' : scalarSqlType(property).jsonType;
110
- array = override.array === true || override.type.endsWith('[]');
111
- }
112
- else if (secure) {
113
- /**
114
- * A secure field is stored as ciphertext, so its declared JSON type says nothing about
115
- * its column. It's always unbounded text: `maxLength` would truncate the ciphertext,
116
- * an enum CHECK would never match it, and jsonb would fail to parse it.
117
- */
118
- sqlType = DEF_SQL_TYPE;
119
- jsonType = 'string';
120
- }
121
- else if (type === 'array') {
122
- const resolved = arraySqlType(property);
123
- sqlType = resolved.sqlType;
124
- jsonType = resolved.jsonType;
125
- array = resolved.array;
126
- }
127
- else {
128
- const resolved = scalarSqlType(property);
129
- sqlType = resolved.sqlType;
130
- jsonType = resolved.jsonType;
131
- }
132
- if (override.length != null) {
133
- sqlType = `character varying(${override.length})`;
134
- }
135
- if (override.array === true && !array && !sqlType.endsWith('[]')) {
136
- sqlType = `${sqlType}[]`;
137
- array = true;
138
- }
139
- if (override.jsonb === true) {
140
- sqlType = DEF_JSON_TYPE;
141
- jsonType = jsonType === 'array' ? 'array' : 'object';
142
- array = false;
143
- }
144
- const formatted = toFormatType(sqlType);
145
- const isJsonb = formatted === 'jsonb' || formatted === 'json';
146
- const column = {
147
- property: name,
148
- /** No implicit snake_casing — a silent rename is a silent DROP plus ADD. */
149
- column: pgIdentifier(override.column ?? name),
150
- sqlType: formatted,
151
- jsonType,
152
- notNull: override.nullable === false ? true : !nullable,
153
- primaryKey: override.primaryKey === true,
154
- secure,
155
- jsonb: isJsonb,
156
- array,
157
- managed: override.managed !== false,
158
- ...(override.using != null ? { using: override.using } : {}),
159
- ...(override.comment != null ? { comment: override.comment } : {})
7
+ export const createPgSchemaHelper = () => {
8
+ const { pgIdentifier, qualify, quoteIdent } = pgNameHelper;
9
+ const toFormatType = (type) => {
10
+ const trimmed = type.trim().toLowerCase();
11
+ const match = /^([a-z0-9_ ]+?)\s*(\(([^)]*)\))?\s*(\[\])?$/.exec(trimmed);
12
+ if (match == null) {
13
+ return trimmed;
14
+ }
15
+ const [, base, , args, array] = match;
16
+ const aliases = {
17
+ int: 'integer', int4: 'integer', int2: 'smallint', int8: 'bigint', serial: 'integer',
18
+ bool: 'boolean', float4: 'real', float8: 'double precision', decimal: 'numeric',
19
+ varchar: 'character varying', char: 'character', bpchar: 'character',
20
+ timestamptz: 'timestamp with time zone', timetz: 'time with time zone',
21
+ timestamp: 'timestamp without time zone', time: 'time without time zone'
22
+ };
23
+ const resolved = aliases[base.trim()] ?? base.trim();
24
+ /** Postgres prints multi-argument typmods without spaces: `numeric(12,2)`. */
25
+ const suffix = args != null ? `(${args.split(',').map(part => part.trim()).join(',')})` : '';
26
+ return `${resolved}${suffix}${array ?? ''}`;
160
27
  };
161
- if (override.defaultRaw != null) {
162
- column.defaultRaw = override.defaultRaw;
163
- }
164
- else if (override.default !== undefined) {
165
- column.defaultLiteral = override.default;
166
- }
167
- else if (property.default !== undefined && !isJsonb && !array) {
168
- /** Non-scalar defaults stay in `getDefaults()` only — they'd need casting in DDL. */
169
- column.defaultLiteral = property.default;
170
- }
171
- return column;
172
- };
173
- const collectProperties = (schema) => {
174
- const properties = { ...schema.properties };
175
- const required = [...(schema.required ?? [])];
176
- /** `allOf` composition is used across the framework's schemas — flatten it like mongo does. */
177
- for (const part of schema.allOf ?? []) {
178
- const nested = collectProperties(part);
179
- Object.assign(properties, nested.properties);
180
- required.push(...nested.required);
181
- }
182
- return { properties, required };
183
- };
184
- /**
185
- * Collapse specs that resolved to the same object name, first declaration wins.
186
- *
187
- * The same index can legitimately be declared twice — a schema-root `pg.indexes` entry, a
188
- * per-property `pg: { index: ... }` override and a `resource.index()` call all land in one
189
- * list, and nothing stops a declaration from repeating one of the others. Two entries with
190
- * one name emit the same `CREATE INDEX` twice inside a single DDL transaction, and Postgres
191
- * answers the second with `42P07 already exists` — which rolls back the whole plan, so the
192
- * table the first statement created never lands and the resource can never initialize.
193
- * Duplicates are a declaration style, not a schema error: dedupe them, don't fail the boot.
194
- */
195
- const byName = (specs, alias, kind) => {
196
- const seen = new Map();
197
- for (const spec of specs) {
198
- const name = spec.name;
199
- if (seen.has(name)) {
200
- log.warn('Duplicate declaration, keeping the first one', { resource: alias, kind, name });
201
- continue;
28
+ const readOverride = (property) => property[PG_KEYWORD] ?? {};
29
+ /**
30
+ * Nullability is decided in exactly one place and threaded down from there. Mongo's mapper
31
+ * recomputes it per branch and lost it twice — on `date-time` and on optional objects.
32
+ */
33
+ const isNullable = (property, isRequired) => {
34
+ const override = readOverride(property);
35
+ if (override.nullable != null) {
36
+ return override.nullable;
202
37
  }
203
- seen.set(name, spec);
204
- }
205
- return [...seen.values()];
206
- };
207
- /**
208
- * Compile a resource's AJV schema into the table specification that drives both DDL
209
- * emission and drift detection.
210
- *
211
- * `extraIndexes` carries what `resource.index()` declared. It's compiled here rather than
212
- * merged afterwards so chained declarations get the same property-to-column mapping and
213
- * the same generated names as schema-borne ones.
214
- *
215
- * @throws {UnsupportedArgumentError} when an override asks for something unrepresentable.
216
- */
217
- export const schemaToTableSpec = (alias, schema, pgSchema, table, autoSync, extraIndexes = []) => {
218
- const raw = (schema ?? {});
219
- const root = raw[PG_KEYWORD] ?? {};
220
- const { properties, required } = collectProperties(raw);
221
- const columns = [];
222
- const uniques = [...(root.unique ?? [])];
223
- const checks = [...(root.checks ?? [])];
224
- const indexes = [...(root.indexes ?? []), ...extraIndexes];
225
- const references = [];
226
- const resolvedTable = pgIdentifier(root.table ?? table);
227
- const resolvedSchema = pgIdentifier(root.schema ?? pgSchema);
228
- for (const [name, property] of Object.entries(properties)) {
229
- const column = compileColumn(name, property, required);
230
- columns.push(column);
38
+ if (property.nullable === true) {
39
+ return true;
40
+ }
41
+ if (Array.isArray(property.type) && property.type.includes('null')) {
42
+ return true;
43
+ }
44
+ return !isRequired;
45
+ };
46
+ const baseType = (property) => Array.isArray(property.type) ? property.type.find(type => type !== 'null') : property.type;
47
+ const scalarSqlType = (property) => {
231
48
  const override = readOverride(property);
232
- if (override.unique != null && override.unique !== false) {
233
- if (column.secure) {
49
+ const type = baseType(property);
50
+ switch (type) {
51
+ case 'string':
52
+ switch (property.format) {
53
+ case 'date-time': return { sqlType: 'timestamp with time zone', jsonType: 'date' };
54
+ case 'date': return { sqlType: 'date', jsonType: 'date' };
55
+ case 'time': return { sqlType: 'time without time zone', jsonType: 'string' };
56
+ case 'uuid': return { sqlType: 'uuid', jsonType: 'string' };
57
+ case 'binary': return { sqlType: 'bytea', jsonType: 'binary' };
58
+ default: break;
59
+ }
60
+ return property.maxLength != null
61
+ ? { sqlType: `character varying(${property.maxLength})`, jsonType: 'string' }
62
+ : { sqlType: DEF_SQL_TYPE, jsonType: 'string' };
63
+ case 'integer':
64
+ return property.maximum != null && property.maximum > INT32_MAX
65
+ ? { sqlType: 'bigint', jsonType: 'bigint' }
66
+ : { sqlType: 'integer', jsonType: 'integer' };
67
+ case 'number':
68
+ return override.precision != null
69
+ ? { sqlType: `numeric(${override.precision},${override.scale ?? 0})`, jsonType: 'number' }
70
+ : { sqlType: 'double precision', jsonType: 'number' };
71
+ case 'boolean':
72
+ return { sqlType: 'boolean', jsonType: 'boolean' };
73
+ case 'object':
234
74
  /**
235
- * Encryption uses a managed nonce, so two identical plaintexts encrypt to different
236
- * ciphertexts — a unique constraint on the column would never collide, silently.
75
+ * The framework's date convention — `{ type: 'object', format: 'date-time' }`. Mongo's
76
+ * mapper broke twice on exactly this branch by dropping nullability, so nullability is
77
+ * decided by the caller, never here.
237
78
  */
238
- throw new UnsupportedArgumentError(`secure-unique:${name}`);
239
- }
240
- uniques.push({
241
- columns: [name],
242
- ...(typeof override.unique === 'string' ? { name: override.unique } : {})
243
- });
79
+ return property.format === 'date-time'
80
+ ? { sqlType: 'timestamp with time zone', jsonType: 'date' }
81
+ : { sqlType: DEF_JSON_TYPE, jsonType: 'object' };
82
+ default:
83
+ return { sqlType: DEF_JSON_TYPE, jsonType: 'unknown' };
244
84
  }
245
- if (override.index != null && override.index !== false) {
246
- if (column.secure) {
247
- log.warn('Index on a secure column will never match: encryption is non-deterministic', {
248
- resource: alias, column: name,
249
- });
250
- }
251
- const specs = override.index === true
252
- ? [{ columns: [name] }]
253
- : Array.isArray(override.index) ? override.index : [override.index];
254
- indexes.push(...specs.map(spec => ({ ...spec, columns: spec.columns ?? [name] })));
85
+ };
86
+ const arraySqlType = (property) => {
87
+ const items = property.items;
88
+ const itemType = items != null ? baseType(items) : undefined;
89
+ /** Only scalar element types become Postgres arrays; anything else stays jsonb. */
90
+ if (items != null && itemType != null && ['string', 'integer', 'number', 'boolean'].includes(itemType)
91
+ && items.format == null && items.enum == null) {
92
+ const scalar = scalarSqlType(items);
93
+ return { sqlType: `${scalar.sqlType}[]`, jsonType: 'array', array: true };
255
94
  }
256
- if (override.references != null) {
257
- references.push({
258
- ...override.references,
259
- property: name,
260
- column: override.references.column ?? ID_FIELD,
261
- name: pgIdentifier(override.references.name ?? `${resolvedTable}_${column.column}_fkey`)
262
- });
95
+ return { sqlType: DEF_JSON_TYPE, jsonType: 'array', array: false };
96
+ };
97
+ const compileColumn = (name, property, required) => {
98
+ const override = readOverride(property);
99
+ const secure = property.secure === true;
100
+ const type = baseType(property);
101
+ const nullable = isNullable(property, required.includes(name));
102
+ let sqlType;
103
+ let jsonType;
104
+ let array = false;
105
+ if (override.type != null) {
106
+ sqlType = override.type;
107
+ jsonType = secure ? 'string' : scalarSqlType(property).jsonType;
108
+ array = override.array === true || override.type.endsWith('[]');
263
109
  }
264
- if (override.check != null) {
265
- checks.push({
266
- name: `${resolvedTable}_${column.column}_check`,
267
- expression: override.check.replace(/\{\{col\}\}/g, quoteIdent(column.column))
268
- });
110
+ else if (secure) {
111
+ /**
112
+ * A secure field is stored as ciphertext, so its declared JSON type says nothing about
113
+ * its column. It's always unbounded text: `maxLength` would truncate the ciphertext,
114
+ * an enum CHECK would never match it, and jsonb would fail to parse it.
115
+ */
116
+ sqlType = DEF_SQL_TYPE;
117
+ jsonType = 'string';
269
118
  }
270
- /** A string enum becomes a CHECK — a native PG enum can't drop values and can't be altered in a transaction. */
271
- if (property.enum != null && !column.secure && override.type == null
272
- && baseType(property) === 'string' && property.enum.every(value => typeof value === 'string')) {
273
- const values = property.enum.map(value => `'${value.replace(/'/g, "''")}'`).join(', ');
274
- const predicate = column.notNull
275
- ? `${quoteIdent(column.column)} IN (${values})`
276
- : `${quoteIdent(column.column)} IS NULL OR ${quoteIdent(column.column)} IN (${values})`;
277
- checks.push({ name: `${resolvedTable}_${column.column}_enum`, expression: predicate });
119
+ else if (type === 'array') {
120
+ const resolved = arraySqlType(property);
121
+ sqlType = resolved.sqlType;
122
+ jsonType = resolved.jsonType;
123
+ array = resolved.array;
278
124
  }
279
- }
280
- const byProperty = {};
281
- const byColumn = {};
282
- for (const column of columns) {
283
- byProperty[column.property] = column;
284
- byColumn[column.column] = column;
285
- }
286
- let primaryKey = [];
287
- if (root.primaryKey != null && root.primaryKey.length > 0) {
288
- primaryKey = root.primaryKey.map(property => {
289
- const column = byProperty[property];
290
- if (column == null) {
291
- throw new UnsupportedArgumentError(`primary-key:${property}`);
125
+ else {
126
+ const resolved = scalarSqlType(property);
127
+ sqlType = resolved.sqlType;
128
+ jsonType = resolved.jsonType;
129
+ }
130
+ if (override.length != null) {
131
+ sqlType = `character varying(${override.length})`;
132
+ }
133
+ if (override.array === true && !array && !sqlType.endsWith('[]')) {
134
+ sqlType = `${sqlType}[]`;
135
+ array = true;
136
+ }
137
+ if (override.jsonb === true) {
138
+ sqlType = DEF_JSON_TYPE;
139
+ jsonType = jsonType === 'array' ? 'array' : 'object';
140
+ array = false;
141
+ }
142
+ const formatted = toFormatType(sqlType);
143
+ const isJsonb = formatted === 'jsonb' || formatted === 'json';
144
+ const column = {
145
+ property: name,
146
+ /** No implicit snake_casing — a silent rename is a silent DROP plus ADD. */
147
+ column: pgIdentifier(override.column ?? name),
148
+ sqlType: formatted,
149
+ jsonType,
150
+ notNull: override.nullable === false ? true : !nullable,
151
+ primaryKey: override.primaryKey === true,
152
+ secure,
153
+ jsonb: isJsonb,
154
+ array,
155
+ managed: override.managed !== false,
156
+ ...(override.using != null ? { using: override.using } : {}),
157
+ ...(override.comment != null ? { comment: override.comment } : {})
158
+ };
159
+ if (override.defaultRaw != null) {
160
+ column.defaultRaw = override.defaultRaw;
161
+ }
162
+ else if (override.default !== undefined) {
163
+ column.defaultLiteral = override.default;
164
+ }
165
+ else if (property.default !== undefined && !isJsonb && !array) {
166
+ /** Non-scalar defaults stay in `getDefaults()` only — they'd need casting in DDL. */
167
+ column.defaultLiteral = property.default;
168
+ }
169
+ return column;
170
+ };
171
+ const collectProperties = (schema) => {
172
+ const properties = { ...schema.properties };
173
+ const required = [...(schema.required ?? [])];
174
+ /** `allOf` composition is used across the framework's schemas — flatten it like mongo does. */
175
+ for (const part of schema.allOf ?? []) {
176
+ const nested = collectProperties(part);
177
+ Object.assign(properties, nested.properties);
178
+ required.push(...nested.required);
179
+ }
180
+ return { properties, required };
181
+ };
182
+ /**
183
+ * Collapse specs that resolved to the same object name, first declaration wins.
184
+ *
185
+ * The same index can legitimately be declared twice — a schema-root `pg.indexes` entry, a
186
+ * per-property `pg: { index: ... }` override and a `resource.index()` call all land in one
187
+ * list, and nothing stops a declaration from repeating one of the others. Two entries with
188
+ * one name emit the same `CREATE INDEX` twice inside a single DDL transaction, and Postgres
189
+ * answers the second with `42P07 already exists` — which rolls back the whole plan, so the
190
+ * table the first statement created never lands and the resource can never initialize.
191
+ * Duplicates are a declaration style, not a schema error: dedupe them, don't fail the boot.
192
+ */
193
+ const byName = (specs, alias, kind) => {
194
+ const seen = new Map();
195
+ for (const spec of specs) {
196
+ const name = spec.name;
197
+ if (seen.has(name)) {
198
+ log.warn('Duplicate declaration, keeping the first one', { resource: alias, kind, name });
199
+ continue;
292
200
  }
293
- column.notNull = true;
294
- return column.column;
295
- });
296
- }
297
- else {
298
- const explicit = columns.filter(column => column.primaryKey);
299
- if (explicit.length > 0) {
300
- primaryKey = explicit.map(column => {
201
+ seen.set(name, spec);
202
+ }
203
+ return [...seen.values()];
204
+ };
205
+ const schemaToTableSpec = (alias, schema, pgSchema, table, autoSync, extraIndexes = []) => {
206
+ const raw = (schema ?? {});
207
+ const root = raw[PG_KEYWORD] ?? {};
208
+ const { properties, required } = collectProperties(raw);
209
+ const columns = [];
210
+ const uniques = [...(root.unique ?? [])];
211
+ const checks = [...(root.checks ?? [])];
212
+ const indexes = [...(root.indexes ?? []), ...extraIndexes];
213
+ const references = [];
214
+ const resolvedTable = pgIdentifier(root.table ?? table);
215
+ const resolvedSchema = pgIdentifier(root.schema ?? pgSchema);
216
+ for (const [name, property] of Object.entries(properties)) {
217
+ const column = compileColumn(name, property, required);
218
+ columns.push(column);
219
+ const override = readOverride(property);
220
+ if (override.unique != null && override.unique !== false) {
221
+ if (column.secure) {
222
+ /**
223
+ * Encryption uses a managed nonce, so two identical plaintexts encrypt to different
224
+ * ciphertexts — a unique constraint on the column would never collide, silently.
225
+ */
226
+ throw new UnsupportedArgumentError(`secure-unique:${name}`);
227
+ }
228
+ uniques.push({
229
+ columns: [name],
230
+ ...(typeof override.unique === 'string' ? { name: override.unique } : {})
231
+ });
232
+ }
233
+ if (override.index != null && override.index !== false) {
234
+ if (column.secure) {
235
+ log.warn('Index on a secure column will never match: encryption is non-deterministic', {
236
+ resource: alias, column: name,
237
+ });
238
+ }
239
+ const specs = override.index === true
240
+ ? [{ columns: [name] }]
241
+ : Array.isArray(override.index) ? override.index : [override.index];
242
+ indexes.push(...specs.map(spec => ({ ...spec, columns: spec.columns ?? [name] })));
243
+ }
244
+ if (override.references != null) {
245
+ references.push({
246
+ ...override.references,
247
+ property: name,
248
+ column: override.references.column ?? ID_FIELD,
249
+ name: pgIdentifier(override.references.name ?? `${resolvedTable}_${column.column}_fkey`)
250
+ });
251
+ }
252
+ if (override.check != null) {
253
+ checks.push({
254
+ name: `${resolvedTable}_${column.column}_check`,
255
+ expression: override.check.replace(/\{\{col\}\}/g, quoteIdent(column.column))
256
+ });
257
+ }
258
+ /** A string enum becomes a CHECK — a native PG enum can't drop values and can't be altered in a transaction. */
259
+ if (property.enum != null && !column.secure && override.type == null
260
+ && baseType(property) === 'string' && property.enum.every(value => typeof value === 'string')) {
261
+ const values = property.enum.map(value => `'${value.replace(/'/g, "''")}'`).join(', ');
262
+ const predicate = column.notNull
263
+ ? `${quoteIdent(column.column)} IN (${values})`
264
+ : `${quoteIdent(column.column)} IS NULL OR ${quoteIdent(column.column)} IN (${values})`;
265
+ checks.push({ name: `${resolvedTable}_${column.column}_enum`, expression: predicate });
266
+ }
267
+ }
268
+ const byProperty = {};
269
+ const byColumn = {};
270
+ for (const column of columns) {
271
+ byProperty[column.property] = column;
272
+ byColumn[column.column] = column;
273
+ }
274
+ let primaryKey = [];
275
+ if (root.primaryKey != null && root.primaryKey.length > 0) {
276
+ primaryKey = root.primaryKey.map(property => {
277
+ const column = byProperty[property];
278
+ if (column == null) {
279
+ throw new UnsupportedArgumentError(`primary-key:${property}`);
280
+ }
301
281
  column.notNull = true;
302
282
  return column.column;
303
283
  });
304
284
  }
305
285
  else {
306
- /**
307
- * Every OwlMeans record is addressed by `id`, so the column is synthesized when the
308
- * schema doesn't declare it — `create()` and `load()` both assume it exists.
309
- */
310
- let id = byProperty[ID_FIELD];
311
- if (id == null) {
312
- id = {
313
- property: ID_FIELD, column: ID_FIELD, sqlType: DEF_SQL_TYPE, jsonType: 'string',
314
- notNull: true, primaryKey: true, secure: false, jsonb: false, array: false,
315
- managed: true, defaultRaw: DEF_ID_DEFAULT
316
- };
317
- columns.unshift(id);
318
- byProperty[ID_FIELD] = id;
319
- byColumn[ID_FIELD] = id;
286
+ const explicit = columns.filter(column => column.primaryKey);
287
+ if (explicit.length > 0) {
288
+ primaryKey = explicit.map(column => {
289
+ column.notNull = true;
290
+ return column.column;
291
+ });
320
292
  }
321
293
  else {
322
- id.notNull = true;
323
- id.primaryKey = true;
324
- if (id.defaultRaw == null && id.defaultLiteral === undefined) {
325
- id.defaultRaw = id.sqlType === 'uuid' ? 'gen_random_uuid()' : DEF_ID_DEFAULT;
294
+ /**
295
+ * Every OwlMeans record is addressed by `id`, so the column is synthesized when the
296
+ * schema doesn't declare it — `create()` and `load()` both assume it exists.
297
+ */
298
+ let id = byProperty[ID_FIELD];
299
+ if (id == null) {
300
+ id = {
301
+ property: ID_FIELD, column: ID_FIELD, sqlType: DEF_SQL_TYPE, jsonType: 'string',
302
+ notNull: true, primaryKey: true, secure: false, jsonb: false, array: false,
303
+ managed: true, defaultRaw: DEF_ID_DEFAULT
304
+ };
305
+ columns.unshift(id);
306
+ byProperty[ID_FIELD] = id;
307
+ byColumn[ID_FIELD] = id;
326
308
  }
309
+ else {
310
+ id.notNull = true;
311
+ id.primaryKey = true;
312
+ if (id.defaultRaw == null && id.defaultLiteral === undefined) {
313
+ id.defaultRaw = id.sqlType === 'uuid' ? 'gen_random_uuid()' : DEF_ID_DEFAULT;
314
+ }
315
+ }
316
+ primaryKey = [id.column];
327
317
  }
328
- primaryKey = [id.column];
329
- }
330
- }
331
- /** Index and unique specs address properties; map them onto physical columns once, here. */
332
- const mapColumns = (names) => names.map(name => {
333
- const column = byProperty[name];
334
- if (column == null) {
335
- throw new UnsupportedArgumentError(`unknown-property:${name}`);
336
318
  }
337
- return column.column;
338
- });
339
- const resolvedIndexes = byName(indexes.map(spec => {
340
- const list = spec.columns == null
341
- ? []
342
- : mapColumns(Array.isArray(spec.columns) ? spec.columns : [spec.columns]);
343
- const suffix = spec.unique === true ? 'uq' : 'idx';
319
+ /** Index and unique specs address properties; map them onto physical columns once, here. */
320
+ const mapColumns = (names) => names.map(name => {
321
+ const column = byProperty[name];
322
+ if (column == null) {
323
+ throw new UnsupportedArgumentError(`unknown-property:${name}`);
324
+ }
325
+ return column.column;
326
+ });
327
+ const resolvedIndexes = byName(indexes.map(spec => {
328
+ const list = spec.columns == null
329
+ ? []
330
+ : mapColumns(Array.isArray(spec.columns) ? spec.columns : [spec.columns]);
331
+ const suffix = spec.unique === true ? 'uq' : 'idx';
332
+ return {
333
+ ...spec,
334
+ columns: list,
335
+ method: spec.method ?? PgIndexMethod.BTree,
336
+ name: pgIdentifier(spec.name ?? `${resolvedTable}_${list.join('_')}_${suffix}`)
337
+ };
338
+ }), alias, 'index');
339
+ const resolvedUniques = byName(uniques.map(spec => {
340
+ const list = mapColumns(spec.columns);
341
+ return { columns: list, name: pgIdentifier(spec.name ?? `${resolvedTable}_${list.join('_')}_key`) };
342
+ }), alias, 'unique');
344
343
  return {
345
- ...spec,
346
- columns: list,
347
- method: spec.method ?? PgIndexMethod.BTree,
348
- name: pgIdentifier(spec.name ?? `${resolvedTable}_${list.join('_')}_${suffix}`)
344
+ alias,
345
+ schema: resolvedSchema,
346
+ table: resolvedTable,
347
+ qualified: qualify(resolvedSchema, resolvedTable),
348
+ columns,
349
+ byProperty,
350
+ byColumn,
351
+ primaryKey,
352
+ uniques: resolvedUniques,
353
+ checks: checks.map(check => ({ ...check, name: pgIdentifier(check.name ?? `${resolvedTable}_check`) })),
354
+ indexes: resolvedIndexes,
355
+ references,
356
+ unmanaged: [
357
+ ...(root.unmanaged ?? []).map(name => byProperty[name]?.column ?? pgIdentifier(name)),
358
+ ...columns.filter(column => !column.managed).map(column => column.column)
359
+ ],
360
+ autoSync: root.autoSync ?? autoSync,
361
+ ...(root.comment != null ? { comment: root.comment } : {})
349
362
  };
350
- }), alias, 'index');
351
- const resolvedUniques = byName(uniques.map(spec => {
352
- const list = mapColumns(spec.columns);
353
- return { columns: list, name: pgIdentifier(spec.name ?? `${resolvedTable}_${list.join('_')}_key`) };
354
- }), alias, 'unique');
355
- return {
356
- alias,
357
- schema: resolvedSchema,
358
- table: resolvedTable,
359
- qualified: qualify(resolvedSchema, resolvedTable),
360
- columns,
361
- byProperty,
362
- byColumn,
363
- primaryKey,
364
- uniques: resolvedUniques,
365
- checks: checks.map(check => ({ ...check, name: pgIdentifier(check.name ?? `${resolvedTable}_check`) })),
366
- indexes: resolvedIndexes,
367
- references,
368
- unmanaged: [
369
- ...(root.unmanaged ?? []).map(name => byProperty[name]?.column ?? pgIdentifier(name)),
370
- ...columns.filter(column => !column.managed).map(column => column.column)
371
- ],
372
- autoSync: root.autoSync ?? autoSync,
373
- ...(root.comment != null ? { comment: root.comment } : {})
374
363
  };
364
+ return { toFormatType, schemaToTableSpec };
375
365
  };
376
- /**
377
- * AJV keyword registration for consumers running in strict mode. The mapper reads the raw
378
- * schema object and never validates through AJV, so this is purely to stop strict mode
379
- * rejecting a schema that carries `pg` overrides.
380
- */
381
- export const pgKeyword = { keyword: PG_KEYWORD, valid: true };
366
+ export const pgSchemaHelper = createPgSchemaHelper();
367
+ /** @deprecated compat:factory-refactor — use `pgSchemaHelper.schemaToTableSpec(…)` */
368
+ export const schemaToTableSpec = (alias, schema, pgSchema, table, autoSync, extraIndexes = []) => pgSchemaHelper.schemaToTableSpec(alias, schema, pgSchema, table, autoSync, extraIndexes);
382
369
  //# sourceMappingURL=schema.js.map