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