@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
@@ -4,177 +4,159 @@ import { types as pgTypes } from 'pg'
4
4
 
5
5
  import { ID_FIELD, PgTypeOid } from '../consts.js'
6
6
  import type { ColumnSpec, TableSpec } from '../types.js'
7
+ import type { PgMarshalHelper } from './marshal/types.js'
7
8
 
8
9
  const log = logger('postgres-resource')
9
10
 
10
- /**
11
- * Parse a temporal value the way the driver would have.
12
- *
13
- * Drizzle's node-postgres session installs its own `getTypeParser` that hands date and
14
- * timestamp columns back as raw text, because its own statically declared column types do
15
- * the conversion. Ours are compiled at runtime and can't, so the driver's parser is called
16
- * directly — the same function, just later. Raw SQL never comes through here, since that
17
- * path keeps the driver's default parsers.
18
- */
19
- const toDate = (value: string, column: ColumnSpec): unknown => {
20
- const oid = column.sqlType.startsWith('timestamp with time zone')
21
- ? PgTypeOid.TimestampTz
22
- : column.sqlType.startsWith('timestamp')
23
- ? PgTypeOid.Timestamp
24
- : PgTypeOid.Date
25
-
26
- return (pgTypes.getTypeParser(oid as never) as (raw: string) => unknown)(value)
27
- }
28
-
29
- const fromDriver = (value: unknown, column: ColumnSpec, table?: string): unknown => {
30
- if (value == null) {
31
- return value
32
- }
33
- /** Ciphertext is opaque — coercing it would corrupt it. */
34
- if (column.secure) {
35
- return value
11
+ export const createPgMarshalHelper = (): PgMarshalHelper => {
12
+ /**
13
+ * Parse a temporal value the way the driver would have.
14
+ *
15
+ * Drizzle's node-postgres session installs its own `getTypeParser` that hands date and
16
+ * timestamp columns back as raw text, because its own statically declared column types do
17
+ * the conversion. Ours are compiled at runtime and can't, so the driver's parser is called
18
+ * directly — the same function, just later. Raw SQL never comes through here, since that
19
+ * path keeps the driver's default parsers.
20
+ */
21
+ const toDate = (value: string, column: ColumnSpec): unknown => {
22
+ const oid = column.sqlType.startsWith('timestamp with time zone')
23
+ ? PgTypeOid.TimestampTz
24
+ : column.sqlType.startsWith('timestamp')
25
+ ? PgTypeOid.Timestamp
26
+ : PgTypeOid.Date
27
+
28
+ return (pgTypes.getTypeParser(oid as never) as (raw: string) => unknown)(value)
36
29
  }
37
30
 
38
- switch (column.jsonType) {
39
- case 'number':
40
- /** Postgres returns `numeric` as a string to protect precision the JS number can't hold. */
41
- return typeof value === 'string' ? parseFloat(value) : value
42
- case 'integer':
43
- return typeof value === 'string' ? parseInt(value, 10) : value
44
- case 'bigint': {
45
- if (typeof value !== 'string' && typeof value !== 'bigint') {
46
- return value
47
- }
48
- const parsed = Number(value)
49
- if (!Number.isSafeInteger(parsed)) {
50
- // Fires per row read: one line per window per column is enough.
51
- if (logThrottle(`postgres-resource:${table ?? ''}:${column.column}:unsafe-bigint`, 300_000)) {
52
- log.warn('Bigint column value exceeds the safe integer range; the value read back is imprecise', {
53
- table, column: column.column,
54
- })
31
+ const fromDriver = (value: unknown, column: ColumnSpec, table?: string): unknown => {
32
+ if (value == null) {
33
+ return value
34
+ }
35
+ /** Ciphertext is opaque — coercing it would corrupt it. */
36
+ if (column.secure) {
37
+ return value
38
+ }
39
+
40
+ switch (column.jsonType) {
41
+ case 'number':
42
+ /** Postgres returns `numeric` as a string to protect precision the JS number can't hold. */
43
+ return typeof value === 'string' ? parseFloat(value) : value
44
+ case 'integer':
45
+ return typeof value === 'string' ? parseInt(value, 10) : value
46
+ case 'bigint': {
47
+ if (typeof value !== 'string' && typeof value !== 'bigint') {
48
+ return value
49
+ }
50
+ const parsed = Number(value)
51
+ if (!Number.isSafeInteger(parsed)) {
52
+ // Fires per row read: one line per window per column is enough.
53
+ if (logThrottle(`postgres-resource:${table ?? ''}:${column.column}:unsafe-bigint`, 300_000)) {
54
+ log.warn('Bigint column value exceeds the safe integer range; the value read back is imprecise', {
55
+ table, column: column.column,
56
+ })
57
+ }
55
58
  }
59
+
60
+ return parsed
56
61
  }
62
+ case 'binary':
63
+ return Buffer.isBuffer(value) ? value.toString('base64') : value
64
+ case 'date':
65
+ return typeof value === 'string' ? toDate(value, column) : value
66
+ default:
67
+ return value
68
+ }
69
+ }
57
70
 
58
- return parsed
71
+ const toDriver = (value: unknown, column: ColumnSpec): unknown => {
72
+ if (value == null) {
73
+ return value
59
74
  }
60
- case 'binary':
61
- return Buffer.isBuffer(value) ? value.toString('base64') : value
62
- case 'date':
63
- return typeof value === 'string' ? toDate(value, column) : value
64
- default:
75
+ if (column.secure) {
65
76
  return value
66
- }
67
- }
77
+ }
78
+ if (column.jsonb) {
79
+ /**
80
+ * node-postgres stringifies plain objects on its own but turns arrays into Postgres
81
+ * array literals, which a jsonb column rejects. Stringifying here covers both.
82
+ */
83
+ return typeof value === 'string' ? value : JSON.stringify(value)
84
+ }
85
+ if (column.jsonType === 'date' && !(value instanceof Date)) {
86
+ return new Date(value as string | number)
87
+ }
88
+ if (column.jsonType === 'binary' && typeof value === 'string') {
89
+ return Buffer.from(value, 'base64')
90
+ }
68
91
 
69
- const toDriver = (value: unknown, column: ColumnSpec): unknown => {
70
- if (value == null) {
71
92
  return value
72
93
  }
73
- if (column.secure) {
74
- return value
75
- }
76
- if (column.jsonb) {
77
- /**
78
- * node-postgres stringifies plain objects on its own but turns arrays into Postgres
79
- * array literals, which a jsonb column rejects. Stringifying here covers both.
80
- */
81
- return typeof value === 'string' ? value : JSON.stringify(value)
82
- }
83
- if (column.jsonType === 'date' && !(value instanceof Date)) {
84
- return new Date(value as string | number)
85
- }
86
- if (column.jsonType === 'binary' && typeof value === 'string') {
87
- return Buffer.from(value, 'base64')
88
- }
89
-
90
- return value
91
- }
92
94
 
93
- /**
94
- * Turn a driver row into a record: physical column names back to schema property names,
95
- * plus the coercions node-postgres leaves to the caller.
96
- *
97
- * Columns the spec doesn't know about — computed expressions, joined columns from custom
98
- * SQL — are kept verbatim. A join result is more useful than a lossy projection.
99
- */
100
- export const rowToRecord = <T extends ResourceRecord>(row: Record<string, unknown>, spec: TableSpec): T => {
101
- const record: Record<string, unknown> = {}
102
- for (const [key, value] of Object.entries(row)) {
103
- const column = spec.byColumn[key]
104
- if (column == null) {
105
- record[key] = value
106
- continue
95
+ const rowToRecord = <T extends ResourceRecord>(row: Record<string, unknown>, spec: TableSpec): T => {
96
+ const record: Record<string, unknown> = {}
97
+ for (const [key, value] of Object.entries(row)) {
98
+ const column = spec.byColumn[key]
99
+ if (column == null) {
100
+ record[key] = value
101
+ continue
102
+ }
103
+ record[column.property] = fromDriver(value, column, spec.qualified)
107
104
  }
108
- record[column.property] = fromDriver(value, column, spec.qualified)
109
- }
110
- if (record[ID_FIELD] != null) {
111
- record[ID_FIELD] = `${record[ID_FIELD]}`
105
+ if (record[ID_FIELD] != null) {
106
+ record[ID_FIELD] = `${record[ID_FIELD]}`
107
+ }
108
+
109
+ return record as T
112
110
  }
113
111
 
114
- return record as T
115
- }
112
+ const resultToRecord = <T extends ResourceRecord>(
113
+ row: Record<string, unknown>, spec: TableSpec
114
+ ): T => {
115
+ const record: Record<string, unknown> = {}
116
+ for (const [key, value] of Object.entries(row)) {
117
+ const column = spec.byProperty[key]
118
+ record[key] = column == null ? value : fromDriver(value, column, spec.qualified)
119
+ }
120
+ if (record[ID_FIELD] != null) {
121
+ record[ID_FIELD] = `${record[ID_FIELD]}`
122
+ }
116
123
 
117
- /**
118
- * Same coercion as {@link rowToRecord}, for rows Drizzle produced.
119
- *
120
- * Drizzle keys its results by the table object's JS property names — which are the schema
121
- * property names — where raw SQL returns physical column names. Two functions rather than
122
- * one lookup that tries both, because a schema that renames `a` to column `b` while another
123
- * property is itself named `b` would make the combined lookup pick the wrong spec.
124
- */
125
- export const resultToRecord = <T extends ResourceRecord>(
126
- row: Record<string, unknown>, spec: TableSpec
127
- ): T => {
128
- const record: Record<string, unknown> = {}
129
- for (const [key, value] of Object.entries(row)) {
130
- const column = spec.byProperty[key]
131
- record[key] = column == null ? value : fromDriver(value, column, spec.qualified)
132
- }
133
- if (record[ID_FIELD] != null) {
134
- record[ID_FIELD] = `${record[ID_FIELD]}`
124
+ return record as T
135
125
  }
136
126
 
137
- return record as T
138
- }
139
-
140
- /**
141
- * Turn a record into the values Drizzle inserts, keyed by property name (Drizzle owns the
142
- * property to column mapping).
143
- *
144
- * `undefined` properties are dropped so a partial write touches only what was supplied;
145
- * an explicit `null` is kept, because nulling a column is a real intent.
146
- */
147
- export const recordToValues = (record: Record<string, unknown>, spec: TableSpec): Record<string, unknown> => {
148
- const values: Record<string, unknown> = {}
149
- for (const [key, value] of Object.entries(record)) {
150
- const column = spec.byProperty[key]
151
- if (column == null || value === undefined) {
152
- continue
127
+ const recordToValues = (record: Record<string, unknown>, spec: TableSpec): Record<string, unknown> => {
128
+ const values: Record<string, unknown> = {}
129
+ for (const [key, value] of Object.entries(record)) {
130
+ const column = spec.byProperty[key]
131
+ if (column == null || value === undefined) {
132
+ continue
133
+ }
134
+ values[column.property] = toDriver(value, column)
153
135
  }
154
- values[column.property] = toDriver(value, column)
155
- }
156
136
 
157
- return values
158
- }
137
+ return values
138
+ }
159
139
 
160
- /**
161
- * Every managed column, with anything the record omits explicitly nulled.
162
- *
163
- * This is what makes `update()` a replace rather than a merge — the semantics mongo's
164
- * `replaceOne` gives, kept identical so a resource behaves the same on either backend.
165
- * The primary key and unmanaged columns are never nulled.
166
- */
167
- export const recordToFullValues = (
168
- record: Record<string, unknown>, spec: TableSpec
169
- ): Record<string, unknown> => {
170
- const values: Record<string, unknown> = {}
171
- for (const column of spec.columns) {
172
- if (spec.primaryKey.includes(column.column) || spec.unmanaged.includes(column.column)) {
173
- continue
140
+ const recordToFullValues = (
141
+ record: Record<string, unknown>, spec: TableSpec
142
+ ): Record<string, unknown> => {
143
+ const values: Record<string, unknown> = {}
144
+ for (const column of spec.columns) {
145
+ if (spec.primaryKey.includes(column.column) || spec.unmanaged.includes(column.column)) {
146
+ continue
147
+ }
148
+ const value = record[column.property]
149
+ values[column.property] = value === undefined ? null : toDriver(value, column)
174
150
  }
175
- const value = record[column.property]
176
- values[column.property] = value === undefined ? null : toDriver(value, column)
151
+
152
+ return values
177
153
  }
178
154
 
179
- return values
155
+ return { rowToRecord, resultToRecord, recordToValues, recordToFullValues }
180
156
  }
157
+
158
+ export const pgMarshalHelper = createPgMarshalHelper()
159
+
160
+ /** @deprecated compat:factory-refactor — use `pgMarshalHelper.rowToRecord(…)` */
161
+ export const rowToRecord = <T extends ResourceRecord>(row: Record<string, unknown>, spec: TableSpec): T =>
162
+ pgMarshalHelper.rowToRecord<T>(row, spec)
@@ -1,9 +1,9 @@
1
1
  import type { Migration, MigrationStore } from '@owlmeans/resource'
2
2
  import type { PoolClient, QueryResultRow } from 'pg'
3
3
 
4
- import { pgErrorToResourceError } from '../errors.js'
4
+ import { pgErrorHelper } from '../pg-error.js'
5
5
  import type { PostgresTx, TableSpec } from '../types.js'
6
- import { qualify, quoteIdent } from './name.js'
6
+ import { pgNameHelper } from './name.js'
7
7
 
8
8
  /**
9
9
  * A transaction façade over a checked out client. `{{alias}}` resolution is injected so
@@ -11,41 +11,45 @@ import { qualify, quoteIdent } from './name.js'
11
11
  */
12
12
  export const makeTx = (
13
13
  client: PoolClient, resolve: (text: string) => string, ref: (alias?: string) => string
14
- ): PostgresTx => ({
15
- client,
14
+ ): PostgresTx => {
15
+ const { pgErrorToResourceError } = pgErrorHelper
16
16
 
17
- query: async <Row extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) => {
18
- const query = resolve(text)
19
- try {
20
- const result = await client.query<Row>(query, params as never[])
21
- return result.rows
22
- } catch (error) {
23
- throw pgErrorToResourceError(error, query)
24
- }
25
- },
17
+ return {
18
+ client,
26
19
 
27
- queryOne: async <Row extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) => {
28
- const query = resolve(text)
29
- try {
30
- const result = await client.query<Row>(query, params as never[])
31
- return result.rows[0] ?? null
32
- } catch (error) {
33
- throw pgErrorToResourceError(error, query)
34
- }
35
- },
20
+ query: async <Row extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) => {
21
+ const query = resolve(text)
22
+ try {
23
+ const result = await client.query<Row>(query, params as never[])
24
+ return result.rows
25
+ } catch (error) {
26
+ throw pgErrorToResourceError(error, query)
27
+ }
28
+ },
36
29
 
37
- execute: async (text: string, params?: unknown[]) => {
38
- const query = resolve(text)
39
- try {
40
- const result = await client.query(query, params as never[])
41
- return result.rowCount ?? 0
42
- } catch (error) {
43
- throw pgErrorToResourceError(error, query)
44
- }
45
- },
30
+ queryOne: async <Row extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) => {
31
+ const query = resolve(text)
32
+ try {
33
+ const result = await client.query<Row>(query, params as never[])
34
+ return result.rows[0] ?? null
35
+ } catch (error) {
36
+ throw pgErrorToResourceError(error, query)
37
+ }
38
+ },
46
39
 
47
- ref
48
- })
40
+ execute: async (text: string, params?: unknown[]) => {
41
+ const query = resolve(text)
42
+ try {
43
+ const result = await client.query(query, params as never[])
44
+ return result.rowCount ?? 0
45
+ } catch (error) {
46
+ throw pgErrorToResourceError(error, query)
47
+ }
48
+ },
49
+
50
+ ref
51
+ }
52
+ }
49
53
 
50
54
  /**
51
55
  * Migration ledger, one table per Postgres schema.
@@ -60,6 +64,8 @@ export const makeMigrationStore = (
60
64
  resolve: (text: string) => string,
61
65
  ref: (alias?: string) => string
62
66
  ): MigrationStore<PostgresTx> => {
67
+ const { pgErrorToResourceError } = pgErrorHelper
68
+ const { qualify, quoteIdent } = pgNameHelper
63
69
  const ledger = qualify(spec.schema, table)
64
70
 
65
71
  return {
@@ -0,0 +1,44 @@
1
+ import type { DbConfig, ResourceRecord } from '@owlmeans/resource'
2
+
3
+ import type { PostgresResource } from '../../types.js'
4
+
5
+ /** Postgres identifiers and literals: sanitizing, asserting, quoting and naming. */
6
+ export interface PgNameHelper {
7
+ /**
8
+ * Coerce an arbitrary name into a legal Postgres identifier of at most 63 bytes.
9
+ *
10
+ * Postgres cuts anything past `NAMEDATALEN - 1` off server side and says nothing about
11
+ * it, so two names that differ only beyond byte 63 would collapse into one identifier.
12
+ * Clamping here instead leaves room for a hash suffix that keeps them apart.
13
+ */
14
+ pgIdentifier: (name: string) => string
15
+ /**
16
+ * Assert a value is safe to interpolate as an identifier. Postgres can't bind identifiers
17
+ * as parameters, so every one of them reaches the server as text — which makes this the
18
+ * only thing standing between a config value and injection.
19
+ *
20
+ * @throws {SyntaxError}
21
+ */
22
+ assertSqlIdentifier: (value: string, what?: string) => string
23
+ /** Quote an identifier for emission, doubling any interior quote. */
24
+ quoteIdent: (value: string) => string
25
+ /**
26
+ * Quote a string literal. Only ever used for values Postgres refuses to bind — notably
27
+ * the password in `CREATE ROLE`.
28
+ *
29
+ * @throws {SyntaxError} on a NUL byte, which no escaping makes safe.
30
+ */
31
+ quoteLiteral: (value: string) => string
32
+ /** `"schema"."table"`, each part quoted. */
33
+ qualify: (schema: string, table: string) => string
34
+ /**
35
+ * Physical table name of a resource: the explicit `name`, else the resource alias,
36
+ * prefixed per `DbConfig.resourcePrefix` and sanitized.
37
+ */
38
+ pgTableName: (config: DbConfig, resource: PostgresResource<ResourceRecord>) => string
39
+ /**
40
+ * Derive a pair of int32s for `pg_advisory_lock` from a qualified table name, so every
41
+ * replica booting against the same table serializes on the same key.
42
+ */
43
+ advisoryKey: (qualified: string) => [number, number]
44
+ }
package/src/utils/name.ts CHANGED
@@ -5,74 +5,61 @@ import type { DbConfig, ResourceRecord } from '@owlmeans/resource'
5
5
  import { PG_MAX_IDENTIFIER } from '../consts.js'
6
6
  import type { PostgresResource } from '../types.js'
7
7
  import { SAFE_IDENTIFIER } from './consts.local.js'
8
+ import type { PgNameHelper } from './name/types.js'
8
9
 
9
- /**
10
- * Coerce an arbitrary name into a legal Postgres identifier of at most 63 bytes.
11
- *
12
- * Postgres cuts anything past `NAMEDATALEN - 1` off server side and says nothing about
13
- * it, so two names that differ only beyond byte 63 would collapse into one identifier.
14
- * Clamping here instead leaves room for a hash suffix that keeps them apart.
15
- */
16
- export const pgIdentifier = (name: string): string => {
17
- const sanitized = name.replace(/[^A-Za-z0-9_$]/g, '_')
18
- const prefixed = /^[A-Za-z_]/.test(sanitized) ? sanitized : `_${sanitized}`
19
- if (Buffer.byteLength(prefixed, 'utf8') <= PG_MAX_IDENTIFIER) {
20
- return prefixed
10
+ export const createPgNameHelper = (): PgNameHelper => {
11
+ const pgIdentifier = (name: string): string => {
12
+ const sanitized = name.replace(/[^A-Za-z0-9_$]/g, '_')
13
+ const prefixed = /^[A-Za-z_]/.test(sanitized) ? sanitized : `_${sanitized}`
14
+ if (Buffer.byteLength(prefixed, 'utf8') <= PG_MAX_IDENTIFIER) {
15
+ return prefixed
16
+ }
17
+ const digest = hex.encode(sha256(new TextEncoder().encode(name))).substring(0, 32)
18
+
19
+ return `${prefixed.substring(0, 30)}_${digest}`
21
20
  }
22
- const digest = hex.encode(sha256(new TextEncoder().encode(name))).substring(0, 32)
23
21
 
24
- return `${prefixed.substring(0, 30)}_${digest}`
25
- }
22
+ const assertSqlIdentifier = (value: string, what: string = 'identifier'): string => {
23
+ if (!SAFE_IDENTIFIER.test(value) || Buffer.byteLength(value, 'utf8') > PG_MAX_IDENTIFIER) {
24
+ throw new SyntaxError(`postgres:unsafe-${what}:${value}`)
25
+ }
26
26
 
27
- /**
28
- * Assert a value is safe to interpolate as an identifier. Postgres can't bind identifiers
29
- * as parameters, so every one of them reaches the server as text — which makes this the
30
- * only thing standing between a config value and injection.
31
- *
32
- * @throws {SyntaxError}
33
- */
34
- export const assertSqlIdentifier = (value: string, what: string = 'identifier'): string => {
35
- if (!SAFE_IDENTIFIER.test(value) || Buffer.byteLength(value, 'utf8') > PG_MAX_IDENTIFIER) {
36
- throw new SyntaxError(`postgres:unsafe-${what}:${value}`)
27
+ return value
37
28
  }
38
29
 
39
- return value
40
- }
30
+ const quoteIdent = (value: string): string => `"${value.replace(/"/g, '""')}"`
31
+
32
+ const quoteLiteral = (value: string): string => {
33
+ if (value.includes('\0')) {
34
+ throw new SyntaxError('postgres:unsafe-literal:nul-byte')
35
+ }
41
36
 
42
- /** Quote an identifier for emission, doubling any interior quote. */
43
- export const quoteIdent = (value: string): string => `"${value.replace(/"/g, '""')}"`
44
-
45
- /**
46
- * Quote a string literal. Only ever used for values Postgres refuses to bind — notably
47
- * the password in `CREATE ROLE`.
48
- *
49
- * @throws {SyntaxError} on a NUL byte, which no escaping makes safe.
50
- */
51
- export const quoteLiteral = (value: string): string => {
52
- if (value.includes('\0')) {
53
- throw new SyntaxError('postgres:unsafe-literal:nul-byte')
37
+ return `'${value.replace(/'/g, "''")}'`
54
38
  }
55
39
 
56
- return `'${value.replace(/'/g, "''")}'`
57
- }
40
+ const qualify = (schema: string, table: string): string =>
41
+ `${quoteIdent(schema)}.${quoteIdent(table)}`
42
+
43
+ const pgTableName = (config: DbConfig, resource: PostgresResource<ResourceRecord>): string =>
44
+ pgIdentifier(`${config.resourcePrefix ?? ''}${resource.name ?? resource.alias}`)
45
+
46
+ const advisoryKey = (qualified: string): [number, number] => {
47
+ const digest = sha256(new TextEncoder().encode(`owlmeans:${qualified}`))
48
+ const view = new DataView(digest.buffer, digest.byteOffset, digest.byteLength)
58
49
 
59
- export const qualify = (schema: string, table: string): string =>
60
- `${quoteIdent(schema)}.${quoteIdent(table)}`
61
-
62
- /**
63
- * Physical table name of a resource: the explicit `name`, else the resource alias,
64
- * prefixed per `DbConfig.resourcePrefix` and sanitized.
65
- */
66
- export const pgTableName = (config: DbConfig, resource: PostgresResource<ResourceRecord>): string =>
67
- pgIdentifier(`${config.resourcePrefix ?? ''}${resource.name ?? resource.alias}`)
68
-
69
- /**
70
- * Derive a pair of int32s for `pg_advisory_lock` from a qualified table name, so every
71
- * replica booting against the same table serializes on the same key.
72
- */
73
- export const advisoryKey = (qualified: string): [number, number] => {
74
- const digest = sha256(new TextEncoder().encode(`owlmeans:${qualified}`))
75
- const view = new DataView(digest.buffer, digest.byteOffset, digest.byteLength)
76
-
77
- return [view.getInt32(0, false), view.getInt32(4, false)]
50
+ return [view.getInt32(0, false), view.getInt32(4, false)]
51
+ }
52
+
53
+ return { pgIdentifier, assertSqlIdentifier, quoteIdent, quoteLiteral, qualify, pgTableName, advisoryKey }
78
54
  }
55
+
56
+ export const pgNameHelper = createPgNameHelper()
57
+
58
+ /** @deprecated compat:factory-refactor — use `pgNameHelper.pgIdentifier(…)` */
59
+ export const pgIdentifier = (name: string): string => pgNameHelper.pgIdentifier(name)
60
+
61
+ /** @deprecated compat:factory-refactor — use `pgNameHelper.quoteIdent(…)` */
62
+ export const quoteIdent = (value: string): string => pgNameHelper.quoteIdent(value)
63
+
64
+ /** @deprecated compat:factory-refactor — use `pgNameHelper.advisoryKey(…)` */
65
+ export const advisoryKey = (qualified: string): [number, number] => pgNameHelper.advisoryKey(qualified)
@@ -0,0 +1,26 @@
1
+ import type { AnySchema } from 'ajv'
2
+
3
+ import type { PgIndexSpec, TableSpec } from '../../types.js'
4
+
5
+ /** The JSON Schema → table specification compiler. */
6
+ export interface PgSchemaHelper {
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
+ toFormatType: (type: string) => string
12
+ /**
13
+ * Compile a resource's AJV schema into the table specification that drives both DDL
14
+ * emission and drift detection.
15
+ *
16
+ * `extraIndexes` carries what `resource.index()` declared. It's compiled here rather than
17
+ * merged afterwards so chained declarations get the same property-to-column mapping and
18
+ * the same generated names as schema-borne ones.
19
+ *
20
+ * @throws {UnsupportedArgumentError} when an override asks for something unrepresentable.
21
+ */
22
+ schemaToTableSpec: (
23
+ alias: string, schema: AnySchema | undefined, pgSchema: string, table: string,
24
+ autoSync: boolean, extraIndexes?: PgIndexSpec[]
25
+ ) => TableSpec
26
+ }