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

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