json-rest-api 1.0.21 → 1.0.23

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 (88) hide show
  1. package/CLAUDE.md +1 -2
  2. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +180 -0
  3. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +172 -0
  4. package/docs/API.md +51 -14
  5. package/docs/COMPARISON.md +2 -2
  6. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +28 -1
  7. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +5 -1
  8. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +3 -1
  9. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +12 -60
  10. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +2 -2
  11. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -1
  12. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +2 -0
  13. package/docs/GUIDE/GUIDE_X_Autofiltering.md +355 -0
  14. package/docs/GUIDE/GUIDE_X_Fastify.md +78 -0
  15. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +250 -0
  16. package/docs/GUIDE/GUIDE_X_Query_Projections.md +159 -0
  17. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +199 -0
  18. package/docs/GUIDE/index.md +18 -9
  19. package/docs/QUICKSTART.md +5 -1
  20. package/docs/index.md +1 -1
  21. package/index.js +3 -2
  22. package/package.json +4 -4
  23. package/plugins/core/connectors/express-plugin.js +97 -184
  24. package/plugins/core/connectors/fastify-plugin.js +206 -0
  25. package/plugins/core/connectors/lib/connector-core.js +195 -0
  26. package/plugins/core/connectors/lib/transport-http-helpers.js +122 -0
  27. package/plugins/core/connectors/lib/transport-route-schemas.js +92 -0
  28. package/plugins/core/lib/dbIntrospection.js +1078 -0
  29. package/plugins/core/lib/dbTablesOperations.js +1104 -160
  30. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +5 -2
  31. package/plugins/core/lib/querying/knex-process-includes.js +3 -4
  32. package/plugins/core/lib/querying/knex-query-helpers.js +17 -71
  33. package/plugins/core/lib/querying/knex-relationship-includes.js +466 -206
  34. package/plugins/core/lib/querying/query-builder-utils.js +19 -0
  35. package/plugins/core/lib/querying/query-field-sort-helpers.js +135 -0
  36. package/plugins/core/lib/querying/sort-helpers.js +33 -0
  37. package/plugins/core/lib/querying/storage-adapter-utils.js +51 -0
  38. package/plugins/core/lib/querying/url-helpers.js +8 -2
  39. package/plugins/core/lib/querying-writing/compile-schemas.js +12 -1
  40. package/plugins/core/lib/querying-writing/knex-field-helpers.js +83 -13
  41. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +13 -12
  42. package/plugins/core/lib/querying-writing/query-field-helpers.js +88 -0
  43. package/plugins/core/lib/querying-writing/request-contracts.js +590 -0
  44. package/plugins/core/lib/storage/canonical-storage-mapping.js +113 -0
  45. package/plugins/core/lib/storage/storage-adapter.js +79 -2
  46. package/plugins/core/lib/storage/storage-mapping.js +204 -0
  47. package/plugins/core/rest-api-anyapi-knex-plugin.js +355 -228
  48. package/plugins/core/rest-api-autofilter-plugin.js +414 -0
  49. package/plugins/core/rest-api-knex-plugin.js +249 -211
  50. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +10 -5
  51. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +12 -6
  52. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +41 -0
  53. package/plugins/core/rest-api-plugin-methods/common.js +121 -83
  54. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +6 -1
  55. package/plugins/core/rest-api-plugin-methods/get.js +23 -14
  56. package/plugins/core/rest-api-plugin-methods/patch.js +28 -12
  57. package/plugins/core/rest-api-plugin-methods/post.js +13 -8
  58. package/plugins/core/rest-api-plugin-methods/put.js +45 -21
  59. package/plugins/core/rest-api-plugin-methods/query.js +15 -53
  60. package/plugins/core/rest-api-plugin.js +2 -0
  61. package/plugins/core/rest-api-positioning-plugin.js +1 -1
  62. package/plugins/core/rest-api-query-projections-plugin.js +130 -0
  63. package/plugins/core/socketio-plugin.js +1 -3
  64. package/tests/autofilter.test.js +463 -0
  65. package/tests/custom-id-simplified-post.test.js +75 -0
  66. package/tests/custom-idproperty-relationships.test.js +33 -0
  67. package/tests/db-introspection.test.js +518 -0
  68. package/tests/db-table-operations.test.js +781 -0
  69. package/tests/fastify-plugin.test.js +141 -0
  70. package/tests/field-getters.test.js +1 -2
  71. package/tests/field-setters.test.js +0 -4
  72. package/tests/fixtures/api-configs.js +188 -219
  73. package/tests/helpers/fake-fastify.js +99 -0
  74. package/tests/http-connectors-parity.test.js +235 -0
  75. package/tests/projected-fields.test.js +319 -0
  76. package/tests/relationships.test.js +3 -3
  77. package/tests/searchschema-knex-behavior.test.js +187 -0
  78. package/tests/searchschema-merge.test.js +21 -69
  79. package/tests/storage-mapping.test.js +210 -0
  80. package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +0 -707
  81. package/docs/GUIDE/GUIDE_X_Multihome.md +0 -666
  82. package/plugins/core/lib/querying-writing/payload-validators.js +0 -1314
  83. package/plugins/core/rest-api-access.js +0 -459
  84. package/plugins/core/rest-api-multihome-plugin.js +0 -314
  85. package/tests/multihome.test.js +0 -553
  86. package/tests/multihome.test.js.bak +0 -550
  87. package/tests/ownership-optout.test.js +0 -76
  88. package/tests/rest-api-access.test.js +0 -206
@@ -0,0 +1,1078 @@
1
+ const TABLE_NAME_PATTERN = /^[A-Za-z0-9_]+$/
2
+ const MYSQL_BOOLEAN_TINYINT_PATTERN = /^tinyint\(1\)/i
3
+
4
+ function normalizeText (value) {
5
+ if (value == null) return ''
6
+ return String(value).trim()
7
+ }
8
+
9
+ function toCamelCase (value) {
10
+ const source = normalizeText(value)
11
+ if (!source) return ''
12
+
13
+ return source.replace(/[_-]([a-z0-9])/gi, (_match, next) => next.toUpperCase())
14
+ }
15
+
16
+ function requireKnexRaw (knex) {
17
+ if (!knex || typeof knex.raw !== 'function') {
18
+ throw new TypeError('introspectKnexTableSnapshot requires knex with raw().')
19
+ }
20
+ }
21
+
22
+ function requireTableName (value) {
23
+ const tableName = normalizeText(value)
24
+ if (!tableName) {
25
+ throw new TypeError('introspectKnexTableSnapshot requires tableName.')
26
+ }
27
+ if (!TABLE_NAME_PATTERN.test(tableName)) {
28
+ throw new Error(`Invalid table name "${tableName}". Use letters, numbers, and underscore only.`)
29
+ }
30
+
31
+ return tableName
32
+ }
33
+
34
+ function detectDialect (knex) {
35
+ const clientName = normalizeText(
36
+ knex?.client?.config?.client ||
37
+ knex?.client?.dialect ||
38
+ knex?.client?.driverName
39
+ ).toLowerCase()
40
+
41
+ if (clientName.includes('sqlite')) {
42
+ return 'sqlite'
43
+ }
44
+ if (clientName.includes('mysql')) {
45
+ return 'mysql2'
46
+ }
47
+
48
+ throw new Error(`Unsupported knex client "${clientName || 'unknown'}" for introspectKnexTableSnapshot.`)
49
+ }
50
+
51
+ function normalizeRows (rawResult) {
52
+ if (Array.isArray(rawResult)) {
53
+ if (rawResult.length > 0 && Array.isArray(rawResult[0])) {
54
+ return rawResult[0]
55
+ }
56
+ return rawResult
57
+ }
58
+ if (rawResult && typeof rawResult === 'object' && Array.isArray(rawResult.rows)) {
59
+ return rawResult.rows
60
+ }
61
+ return []
62
+ }
63
+
64
+ function toNullableNumber (value) {
65
+ if (value == null) return null
66
+ const parsed = Number.parseInt(String(value), 10)
67
+ return Number.isFinite(parsed) ? parsed : null
68
+ }
69
+
70
+ function normalizeColumnDefault (value) {
71
+ if (value == null) {
72
+ return null
73
+ }
74
+
75
+ if (typeof value === 'string') {
76
+ const text = value.trim()
77
+ if (!text || text.toLowerCase() === 'null') {
78
+ return null
79
+ }
80
+ if (text.startsWith("'") && text.endsWith("'")) {
81
+ return text.slice(1, -1).replace(/''/g, "'").replace(/\\'/g, "'")
82
+ }
83
+ if (text.startsWith('"') && text.endsWith('"')) {
84
+ return text.slice(1, -1).replace(/""/g, '"')
85
+ }
86
+ return text
87
+ }
88
+
89
+ return value
90
+ }
91
+
92
+ function parseEnumLikeValues (columnType = '', typeName = 'enum') {
93
+ const source = normalizeText(columnType)
94
+ const normalizedType = `${String(typeName || '').toLowerCase()}(`
95
+ if (!source.toLowerCase().startsWith(normalizedType) || !source.endsWith(')')) {
96
+ return []
97
+ }
98
+
99
+ const body = source.slice(normalizedType.length, -1)
100
+ const values = []
101
+ const pattern = /'((?:''|\\'|[^'])*)'/g
102
+ let match = null
103
+ while ((match = pattern.exec(body)) != null) {
104
+ values.push(match[1].replace(/''/g, "'").replace(/\\'/g, "'"))
105
+ }
106
+
107
+ return values
108
+ }
109
+
110
+ function resolveTypeKind ({ name = '', dataType = '', columnType = '' } = {}) {
111
+ const normalizedType = normalizeText(dataType).toLowerCase()
112
+ const normalizedColumnType = normalizeText(columnType).toLowerCase()
113
+
114
+ if (
115
+ normalizedType === 'varchar' ||
116
+ normalizedType === 'char' ||
117
+ normalizedType === 'text' ||
118
+ normalizedType === 'tinytext' ||
119
+ normalizedType === 'mediumtext' ||
120
+ normalizedType === 'longtext' ||
121
+ normalizedType === 'enum' ||
122
+ normalizedType === 'set'
123
+ ) {
124
+ return 'string'
125
+ }
126
+
127
+ if (
128
+ normalizedType === 'int' ||
129
+ normalizedType === 'integer' ||
130
+ normalizedType === 'smallint' ||
131
+ normalizedType === 'mediumint' ||
132
+ normalizedType === 'bigint'
133
+ ) {
134
+ return 'integer'
135
+ }
136
+
137
+ if (normalizedType === 'tinyint') {
138
+ if (MYSQL_BOOLEAN_TINYINT_PATTERN.test(normalizedColumnType)) {
139
+ return 'boolean'
140
+ }
141
+ return 'integer'
142
+ }
143
+
144
+ if (
145
+ normalizedType === 'decimal' ||
146
+ normalizedType === 'numeric' ||
147
+ normalizedType === 'float' ||
148
+ normalizedType === 'double' ||
149
+ normalizedType === 'real'
150
+ ) {
151
+ return 'number'
152
+ }
153
+
154
+ if (normalizedType === 'boolean' || normalizedType === 'bool') {
155
+ return 'boolean'
156
+ }
157
+
158
+ if (normalizedType === 'datetime' || normalizedType === 'timestamp') {
159
+ return 'datetime'
160
+ }
161
+ if (normalizedType === 'date') {
162
+ return 'date'
163
+ }
164
+ if (normalizedType === 'time') {
165
+ return 'time'
166
+ }
167
+ if (normalizedType === 'json') {
168
+ return 'json'
169
+ }
170
+ if (
171
+ normalizedType === 'blob' ||
172
+ normalizedType === 'binary' ||
173
+ normalizedType === 'varbinary'
174
+ ) {
175
+ return 'binary'
176
+ }
177
+
178
+ throw new Error(`Unsupported column type "${normalizedType}" for column "${name}".`)
179
+ }
180
+
181
+ function buildColumnSnapshot ({
182
+ name = '',
183
+ dataType = '',
184
+ columnType = '',
185
+ nullable = false,
186
+ defaultValue = null,
187
+ extra = '',
188
+ autoIncrement = false,
189
+ unsigned = false,
190
+ maxLength = null,
191
+ numericPrecision = null,
192
+ numericScale = null,
193
+ datetimePrecision = null,
194
+ characterSetName = '',
195
+ collationName = '',
196
+ ordinalPosition = null,
197
+ enumValues = null,
198
+ setValues = null
199
+ } = {}) {
200
+ const normalizedName = normalizeText(name)
201
+ const normalizedDataType = normalizeText(dataType).toLowerCase()
202
+ const normalizedColumnType = normalizeText(columnType)
203
+ const normalizedDefaultValue = normalizeColumnDefault(defaultValue)
204
+
205
+ return {
206
+ name: normalizedName,
207
+ key: toCamelCase(normalizedName),
208
+ dataType: normalizedDataType,
209
+ columnType: normalizedColumnType,
210
+ extra: normalizeText(extra).toLowerCase(),
211
+ typeKind: resolveTypeKind({
212
+ name: normalizedName,
213
+ dataType: normalizedDataType,
214
+ columnType: normalizedColumnType
215
+ }),
216
+ nullable: Boolean(nullable),
217
+ defaultValue: normalizedDefaultValue,
218
+ hasDefault: normalizedDefaultValue != null,
219
+ autoIncrement: Boolean(autoIncrement),
220
+ unsigned: Boolean(unsigned),
221
+ maxLength: toNullableNumber(maxLength),
222
+ numericPrecision: toNullableNumber(numericPrecision),
223
+ numericScale: toNullableNumber(numericScale),
224
+ datetimePrecision: toNullableNumber(datetimePrecision),
225
+ characterSetName: normalizeText(characterSetName),
226
+ collationName: normalizeText(collationName),
227
+ ordinalPosition: toNullableNumber(ordinalPosition),
228
+ enumValues: Array.isArray(enumValues) ? enumValues : parseEnumLikeValues(normalizedColumnType, 'enum'),
229
+ setValues: Array.isArray(setValues) ? setValues : parseEnumLikeValues(normalizedColumnType, 'set')
230
+ }
231
+ }
232
+
233
+ function normalizePrimaryKeyColumns (rows = []) {
234
+ return rows
235
+ .map((row) => normalizeText(row.columnName || row.column_name || row.name))
236
+ .filter(Boolean)
237
+ }
238
+
239
+ function normalizeIndexes (rows = []) {
240
+ const byName = new Map()
241
+
242
+ for (const row of Array.isArray(rows) ? rows : []) {
243
+ const indexName = normalizeText(row.indexName || row.index_name || row.name)
244
+ const columnName = normalizeText(row.columnName || row.column_name || row.name)
245
+ if (!indexName || !columnName) {
246
+ continue
247
+ }
248
+
249
+ const seqInIndex = toNullableNumber(row.seqInIndex ?? row.seq_in_index ?? row.seqno) || 0
250
+ const nonUnique = Number(row.nonUnique ?? row.non_unique) === 1
251
+ const existing = byName.get(indexName) || {
252
+ name: indexName,
253
+ unique: !nonUnique,
254
+ indexType: normalizeText(row.indexType || row.index_type).toUpperCase(),
255
+ columns: []
256
+ }
257
+
258
+ existing.columns.push({
259
+ name: columnName,
260
+ order: seqInIndex
261
+ })
262
+ byName.set(indexName, existing)
263
+ }
264
+
265
+ return [...byName.values()]
266
+ .map((index) => ({
267
+ name: index.name,
268
+ unique: index.unique,
269
+ indexType: index.indexType,
270
+ columns: index.columns
271
+ .sort((left, right) => left.order - right.order)
272
+ .map((column) => column.name)
273
+ }))
274
+ .sort((left, right) => left.name.localeCompare(right.name))
275
+ }
276
+
277
+ function normalizeForeignKeys (rows = [], parsedForeignKeys = [], tableName = '') {
278
+ const grouped = new Map()
279
+
280
+ for (const row of Array.isArray(rows) ? rows : []) {
281
+ const groupId = normalizeText(row.groupId ?? row.id ?? row.constraintName ?? row.constraint_name)
282
+ const columnName = normalizeText(row.columnName || row.column_name || row.from)
283
+ const referencedTableName = normalizeText(row.referencedTableName || row.referenced_table_name || row.table)
284
+ const referencedColumnName = normalizeText(row.referencedColumnName || row.referenced_column_name || row.to)
285
+ if (!groupId || !columnName || !referencedTableName || !referencedColumnName) {
286
+ continue
287
+ }
288
+
289
+ const ordinalPosition = toNullableNumber(row.ordinalPosition ?? row.ordinal_position ?? row.seq) || 0
290
+ const existing = grouped.get(groupId) || {
291
+ groupId,
292
+ name: normalizeText(row.constraintName || row.constraint_name),
293
+ referencedTableName,
294
+ updateRule: normalizeText(row.updateRule || row.update_rule || row.on_update).toUpperCase(),
295
+ deleteRule: normalizeText(row.deleteRule || row.delete_rule || row.on_delete).toUpperCase(),
296
+ columns: []
297
+ }
298
+
299
+ existing.columns.push({
300
+ name: columnName,
301
+ referencedName: referencedColumnName,
302
+ order: ordinalPosition
303
+ })
304
+ grouped.set(groupId, existing)
305
+ }
306
+
307
+ const entries = [...grouped.values()]
308
+ .sort((left, right) => String(left.groupId).localeCompare(String(right.groupId), undefined, { numeric: true }))
309
+ .map((foreignKey, index) => {
310
+ const normalizedColumns = foreignKey.columns
311
+ .sort((left, right) => left.order - right.order)
312
+ .map((column) => ({
313
+ name: column.name,
314
+ referencedName: column.referencedName
315
+ }))
316
+
317
+ const parsedMatch = parsedForeignKeys.find((parsed, parsedIndex) => {
318
+ if (parsed._used) return false
319
+ if (normalizeText(parsed.referencedTableName) !== foreignKey.referencedTableName) return false
320
+ if ((parsed.columns || []).length !== normalizedColumns.length) return false
321
+
322
+ return parsed.columns.every((column, columnIndex) => {
323
+ const target = normalizedColumns[columnIndex]
324
+ return target &&
325
+ normalizeText(column.name) === target.name &&
326
+ normalizeText(column.referencedName) === target.referencedName
327
+ })
328
+ }) || parsedForeignKeys[index] || null
329
+
330
+ if (parsedMatch) {
331
+ parsedMatch._used = true
332
+ }
333
+
334
+ return {
335
+ name: normalizeText(parsedMatch?.name) || normalizeText(foreignKey.name) || `${tableName}_foreign_${index + 1}`,
336
+ referencedTableName: foreignKey.referencedTableName,
337
+ updateRule: foreignKey.updateRule,
338
+ deleteRule: foreignKey.deleteRule,
339
+ columns: normalizedColumns
340
+ }
341
+ })
342
+
343
+ return entries.sort((left, right) => left.name.localeCompare(right.name))
344
+ }
345
+
346
+ function normalizeCheckConstraints (constraints = [], tableName = '') {
347
+ return constraints
348
+ .map((constraint, index) => {
349
+ const name = normalizeText(constraint.name)
350
+ const clause = normalizeText(constraint.clause)
351
+ if (!clause) return null
352
+
353
+ return {
354
+ name: name || `${tableName}_check_${index + 1}`,
355
+ clause
356
+ }
357
+ })
358
+ .filter(Boolean)
359
+ .sort((left, right) => left.name.localeCompare(right.name))
360
+ }
361
+
362
+ function requireIdColumn (columns, idColumn) {
363
+ const normalizedIdColumn = normalizeText(idColumn) || 'id'
364
+ const idSpec = columns.find((column) => column.name === normalizedIdColumn) || null
365
+ if (!idSpec) {
366
+ throw new Error(`Could not find id column "${normalizedIdColumn}" in table.`)
367
+ }
368
+ if (idSpec.typeKind !== 'integer') {
369
+ throw new Error(`Id column "${normalizedIdColumn}" must use an integer type.`)
370
+ }
371
+ if (idSpec.nullable) {
372
+ throw new Error(`Id column "${normalizedIdColumn}" must be not-null.`)
373
+ }
374
+ if (!idSpec.autoIncrement && !idSpec.hasDefault) {
375
+ throw new Error(`Id column "${normalizedIdColumn}" must be auto-incrementing or have a database default.`)
376
+ }
377
+
378
+ return normalizedIdColumn
379
+ }
380
+
381
+ function requirePrimaryKeyContainsId (primaryKeyColumns, idColumn) {
382
+ if (!Array.isArray(primaryKeyColumns) || !primaryKeyColumns.includes(idColumn)) {
383
+ throw new Error(`Primary key must include id column "${idColumn}".`)
384
+ }
385
+ if (primaryKeyColumns.length !== 1 || primaryKeyColumns[0] !== idColumn) {
386
+ throw new Error(`Composite primary keys are not supported. Primary key must be only "${idColumn}".`)
387
+ }
388
+ }
389
+
390
+ function quoteSqliteIdentifier (identifier) {
391
+ return `"${String(identifier || '').replace(/"/g, '""')}"`
392
+ }
393
+
394
+ function extractParenthesizedContent (source = '', openIndex = -1) {
395
+ if (openIndex < 0 || source[openIndex] !== '(') {
396
+ return null
397
+ }
398
+
399
+ let depth = 0
400
+ let inSingleQuote = false
401
+ let inDoubleQuote = false
402
+ let inBacktick = false
403
+
404
+ for (let index = openIndex; index < source.length; index += 1) {
405
+ const char = source[index]
406
+ const next = source[index + 1]
407
+
408
+ if (inSingleQuote) {
409
+ if (char === "'" && next === "'") {
410
+ index += 1
411
+ continue
412
+ }
413
+ if (char === "'") inSingleQuote = false
414
+ continue
415
+ }
416
+ if (inDoubleQuote) {
417
+ if (char === '"' && next === '"') {
418
+ index += 1
419
+ continue
420
+ }
421
+ if (char === '"') inDoubleQuote = false
422
+ continue
423
+ }
424
+ if (inBacktick) {
425
+ if (char === '`') inBacktick = false
426
+ continue
427
+ }
428
+
429
+ if (char === "'") {
430
+ inSingleQuote = true
431
+ continue
432
+ }
433
+ if (char === '"') {
434
+ inDoubleQuote = true
435
+ continue
436
+ }
437
+ if (char === '`') {
438
+ inBacktick = true
439
+ continue
440
+ }
441
+
442
+ if (char === '(') {
443
+ depth += 1
444
+ continue
445
+ }
446
+ if (char === ')') {
447
+ depth -= 1
448
+ if (depth === 0) {
449
+ return {
450
+ content: source.slice(openIndex + 1, index),
451
+ endIndex: index
452
+ }
453
+ }
454
+ }
455
+ }
456
+
457
+ return null
458
+ }
459
+
460
+ function splitTopLevelSqlList (source = '') {
461
+ const entries = []
462
+ let buffer = ''
463
+ let depth = 0
464
+ let inSingleQuote = false
465
+ let inDoubleQuote = false
466
+ let inBacktick = false
467
+
468
+ for (let index = 0; index < source.length; index += 1) {
469
+ const char = source[index]
470
+ const next = source[index + 1]
471
+
472
+ if (inSingleQuote) {
473
+ buffer += char
474
+ if (char === "'" && next === "'") {
475
+ buffer += next
476
+ index += 1
477
+ continue
478
+ }
479
+ if (char === "'") inSingleQuote = false
480
+ continue
481
+ }
482
+ if (inDoubleQuote) {
483
+ buffer += char
484
+ if (char === '"' && next === '"') {
485
+ buffer += next
486
+ index += 1
487
+ continue
488
+ }
489
+ if (char === '"') inDoubleQuote = false
490
+ continue
491
+ }
492
+ if (inBacktick) {
493
+ buffer += char
494
+ if (char === '`') inBacktick = false
495
+ continue
496
+ }
497
+
498
+ if (char === "'") {
499
+ inSingleQuote = true
500
+ buffer += char
501
+ continue
502
+ }
503
+ if (char === '"') {
504
+ inDoubleQuote = true
505
+ buffer += char
506
+ continue
507
+ }
508
+ if (char === '`') {
509
+ inBacktick = true
510
+ buffer += char
511
+ continue
512
+ }
513
+
514
+ if (char === '(') {
515
+ depth += 1
516
+ buffer += char
517
+ continue
518
+ }
519
+ if (char === ')') {
520
+ depth -= 1
521
+ buffer += char
522
+ continue
523
+ }
524
+
525
+ if (char === ',' && depth === 0) {
526
+ const entry = buffer.trim()
527
+ if (entry) entries.push(entry)
528
+ buffer = ''
529
+ continue
530
+ }
531
+
532
+ buffer += char
533
+ }
534
+
535
+ const finalEntry = buffer.trim()
536
+ if (finalEntry) entries.push(finalEntry)
537
+
538
+ return entries
539
+ }
540
+
541
+ function extractCreateTableEntries (tableSql = '') {
542
+ const source = normalizeText(tableSql)
543
+ const openIndex = source.indexOf('(')
544
+ if (openIndex < 0) return []
545
+
546
+ const body = extractParenthesizedContent(source, openIndex)
547
+ if (!body?.content) return []
548
+
549
+ return splitTopLevelSqlList(body.content)
550
+ }
551
+
552
+ function unquoteSqlIdentifier (value = '') {
553
+ const source = normalizeText(value)
554
+ if (!source) return ''
555
+ if (
556
+ (source.startsWith('"') && source.endsWith('"')) ||
557
+ (source.startsWith('`') && source.endsWith('`')) ||
558
+ (source.startsWith('[') && source.endsWith(']'))
559
+ ) {
560
+ return source.slice(1, -1)
561
+ }
562
+
563
+ return source
564
+ }
565
+
566
+ function parseIdentifierList (source = '') {
567
+ return splitTopLevelSqlList(source)
568
+ .map((entry) => unquoteSqlIdentifier(entry))
569
+ .filter(Boolean)
570
+ }
571
+
572
+ function readLeadingIdentifier (entry = '') {
573
+ const match = String(entry).match(/^\s*("([^"]|"")*"|`[^`]+`|\[[^\]]+\]|[A-Za-z0-9_]+)/)
574
+ if (!match) return ''
575
+ return unquoteSqlIdentifier(match[1])
576
+ }
577
+
578
+ function extractConstraintName (entry = '') {
579
+ const match = String(entry).match(/^\s*CONSTRAINT\s+("([^"]|"")*"|`[^`]+`|\[[^\]]+\]|[A-Za-z0-9_]+)/i)
580
+ if (!match) return ''
581
+ return unquoteSqlIdentifier(match[1])
582
+ }
583
+
584
+ function extractFkAction (entry = '', action = 'UPDATE') {
585
+ const pattern = new RegExp(`ON\\s+${action}\\s+(SET\\s+NULL|SET\\s+DEFAULT|NO\\s+ACTION|CASCADE|RESTRICT)`, 'i')
586
+ const match = String(entry).match(pattern)
587
+ return match ? normalizeText(match[1]).replace(/\s+/g, ' ').toUpperCase() : ''
588
+ }
589
+
590
+ function parseSqliteForeignKeyEntry (entry = '') {
591
+ const constraintName = extractConstraintName(entry)
592
+ const trimmed = String(entry).trim()
593
+ const working = constraintName
594
+ ? trimmed.replace(/^\s*CONSTRAINT\s+("([^"]|"")*"|`[^`]+`|\[[^\]]+\]|[A-Za-z0-9_]+)\s+/i, '')
595
+ : trimmed
596
+
597
+ const upper = working.toUpperCase()
598
+
599
+ if (upper.startsWith('FOREIGN KEY')) {
600
+ const columnsStart = working.indexOf('(')
601
+ const sourceColumns = extractParenthesizedContent(working, columnsStart)
602
+ if (!sourceColumns) return null
603
+
604
+ const afterColumns = working.slice(sourceColumns.endIndex + 1)
605
+ const refMatch = afterColumns.match(/REFERENCES\s+("([^"]|"")*"|`[^`]+`|\[[^\]]+\]|[A-Za-z0-9_]+)\s*/i)
606
+ if (!refMatch) return null
607
+
608
+ const referencedTableName = unquoteSqlIdentifier(refMatch[1])
609
+ const refOpenIndex = afterColumns.indexOf('(', refMatch.index + refMatch[0].length - 1)
610
+ const referencedColumns = extractParenthesizedContent(afterColumns, refOpenIndex)
611
+ if (!referencedColumns) return null
612
+
613
+ const columns = parseIdentifierList(sourceColumns.content)
614
+ const referencedNames = parseIdentifierList(referencedColumns.content)
615
+
616
+ return {
617
+ name: constraintName,
618
+ referencedTableName,
619
+ updateRule: extractFkAction(working, 'UPDATE'),
620
+ deleteRule: extractFkAction(working, 'DELETE'),
621
+ columns: columns.map((columnName, index) => ({
622
+ name: columnName,
623
+ referencedName: referencedNames[index] || ''
624
+ }))
625
+ }
626
+ }
627
+
628
+ if (!/\bREFERENCES\b/i.test(working)) {
629
+ return null
630
+ }
631
+
632
+ const columnName = readLeadingIdentifier(working)
633
+ const refMatch = working.match(/REFERENCES\s+("([^"]|"")*"|`[^`]+`|\[[^\]]+\]|[A-Za-z0-9_]+)\s*/i)
634
+ if (!columnName || !refMatch) return null
635
+
636
+ const referencedTableName = unquoteSqlIdentifier(refMatch[1])
637
+ const refOpenIndex = working.indexOf('(', refMatch.index + refMatch[0].length - 1)
638
+ const referencedColumns = extractParenthesizedContent(working, refOpenIndex)
639
+ const referencedNames = referencedColumns ? parseIdentifierList(referencedColumns.content) : ['id']
640
+
641
+ return {
642
+ name: constraintName,
643
+ referencedTableName,
644
+ updateRule: extractFkAction(working, 'UPDATE'),
645
+ deleteRule: extractFkAction(working, 'DELETE'),
646
+ columns: [{
647
+ name: columnName,
648
+ referencedName: referencedNames[0] || 'id'
649
+ }]
650
+ }
651
+ }
652
+
653
+ function parseSqliteCheckEntry (entry = '') {
654
+ const checkIndex = entry.toUpperCase().indexOf('CHECK')
655
+ if (checkIndex < 0) return null
656
+
657
+ const openIndex = entry.indexOf('(', checkIndex)
658
+ const clause = extractParenthesizedContent(entry, openIndex)
659
+ if (!clause?.content) return null
660
+
661
+ return {
662
+ name: extractConstraintName(entry),
663
+ clause: clause.content.trim()
664
+ }
665
+ }
666
+
667
+ function parseSqliteInlineEnumValues (entry = '', columnName = '') {
668
+ const normalizedColumnName = normalizeText(columnName)
669
+ if (!normalizedColumnName) return []
670
+
671
+ const escapedColumnName = normalizedColumnName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
672
+ const pattern = new RegExp(
673
+ `check\\s*\\(\\s*(?:"${escapedColumnName}"|\`${escapedColumnName}\`|\\[${escapedColumnName}\\]|${escapedColumnName})\\s+in\\s*\\(([^)]*)\\)\\s*\\)`,
674
+ 'i'
675
+ )
676
+ const match = String(entry).match(pattern)
677
+ if (!match?.[1]) {
678
+ return []
679
+ }
680
+
681
+ return parseEnumLikeValues(`enum(${match[1]})`, 'enum')
682
+ }
683
+
684
+ function parseSqliteConstraintMetadata (entries = []) {
685
+ const foreignKeys = []
686
+ const checks = []
687
+
688
+ for (const entry of entries) {
689
+ const foreignKey = parseSqliteForeignKeyEntry(entry)
690
+ if (foreignKey) {
691
+ foreignKeys.push(foreignKey)
692
+ }
693
+
694
+ const checkConstraint = parseSqliteCheckEntry(entry)
695
+ if (checkConstraint) {
696
+ checks.push(checkConstraint)
697
+ }
698
+ }
699
+
700
+ return {
701
+ foreignKeys,
702
+ checkConstraints: checks
703
+ }
704
+ }
705
+
706
+ function parseSqliteDeclaredType (columnType = '') {
707
+ const source = normalizeText(columnType)
708
+ const match = source.match(/^([A-Za-z0-9_]+)\s*(?:\((.*)\))?/i)
709
+ return {
710
+ dataType: normalizeText(match?.[1]).toLowerCase(),
711
+ args: normalizeText(match?.[2]),
712
+ columnType: source,
713
+ unsigned: /\bunsigned\b/i.test(source)
714
+ }
715
+ }
716
+
717
+ function deriveSqliteColumnNumbers (dataType, args = '') {
718
+ if (!args) {
719
+ return {
720
+ maxLength: null,
721
+ numericPrecision: null,
722
+ numericScale: null,
723
+ datetimePrecision: null
724
+ }
725
+ }
726
+
727
+ const parts = args.split(',').map((entry) => normalizeText(entry))
728
+ if (dataType === 'varchar' || dataType === 'char') {
729
+ return {
730
+ maxLength: toNullableNumber(parts[0]),
731
+ numericPrecision: null,
732
+ numericScale: null,
733
+ datetimePrecision: null
734
+ }
735
+ }
736
+
737
+ if (dataType === 'decimal' || dataType === 'numeric') {
738
+ return {
739
+ maxLength: null,
740
+ numericPrecision: toNullableNumber(parts[0]),
741
+ numericScale: toNullableNumber(parts[1]),
742
+ datetimePrecision: null
743
+ }
744
+ }
745
+
746
+ if (dataType === 'datetime' || dataType === 'timestamp' || dataType === 'time') {
747
+ return {
748
+ maxLength: null,
749
+ numericPrecision: null,
750
+ numericScale: null,
751
+ datetimePrecision: toNullableNumber(parts[0])
752
+ }
753
+ }
754
+
755
+ return {
756
+ maxLength: null,
757
+ numericPrecision: null,
758
+ numericScale: null,
759
+ datetimePrecision: null
760
+ }
761
+ }
762
+
763
+ async function introspectSqliteTableSnapshot (knex, { tableName, idColumn }) {
764
+ const quotedTableName = quoteSqliteIdentifier(tableName)
765
+ const tableRows = normalizeRows(await knex.raw(
766
+ `SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`,
767
+ [tableName]
768
+ ))
769
+ const firstTableRow = Array.isArray(tableRows) ? tableRows[0] : null
770
+ if (!firstTableRow?.sql) {
771
+ throw new Error(`Could not introspect table "${tableName}".`)
772
+ }
773
+
774
+ const tableSql = String(firstTableRow.sql)
775
+ const tableEntries = extractCreateTableEntries(tableSql)
776
+ const parsedConstraints = parseSqliteConstraintMetadata(tableEntries)
777
+ const tableEntriesByLeadingIdentifier = new Map(
778
+ tableEntries.map((entry) => [readLeadingIdentifier(entry), entry])
779
+ )
780
+
781
+ const columnRows = normalizeRows(await knex.raw(`PRAGMA table_xinfo(${quotedTableName})`))
782
+ .filter((row) => Number(row.hidden || 0) === 0)
783
+ if (columnRows.length < 1) {
784
+ throw new Error(`Could not introspect table "${tableName}".`)
785
+ }
786
+
787
+ const columns = columnRows
788
+ .sort((left, right) => Number(left.cid) - Number(right.cid))
789
+ .map((row) => {
790
+ const declared = parseSqliteDeclaredType(row.type)
791
+ const derived = deriveSqliteColumnNumbers(declared.dataType, declared.args)
792
+ const upperEntry = tableEntriesByLeadingIdentifier.get(normalizeText(row.name)) || ''
793
+ const inlineEnumValues = declared.dataType === 'text'
794
+ ? parseSqliteInlineEnumValues(upperEntry, row.name)
795
+ : []
796
+ const autoIncrement = Number(row.pk) > 0 &&
797
+ declared.dataType === 'integer' &&
798
+ /\bPRIMARY\s+KEY\b/i.test(upperEntry)
799
+
800
+ return buildColumnSnapshot({
801
+ name: row.name,
802
+ dataType: declared.dataType,
803
+ columnType: declared.columnType,
804
+ nullable: Number(row.pk) > 0 ? false : Number(row.notnull) !== 1,
805
+ defaultValue: row.dflt_value,
806
+ extra: autoIncrement ? 'auto_increment' : '',
807
+ autoIncrement,
808
+ unsigned: declared.unsigned,
809
+ maxLength: derived.maxLength,
810
+ numericPrecision: derived.numericPrecision,
811
+ numericScale: derived.numericScale,
812
+ datetimePrecision: derived.datetimePrecision,
813
+ ordinalPosition: Number(row.cid) + 1,
814
+ enumValues: inlineEnumValues
815
+ })
816
+ })
817
+
818
+ const primaryKeyColumns = columnRows
819
+ .filter((row) => Number(row.pk) > 0)
820
+ .sort((left, right) => Number(left.pk) - Number(right.pk))
821
+ .map((row) => normalizeText(row.name))
822
+
823
+ const indexListRows = normalizeRows(await knex.raw(`PRAGMA index_list(${quotedTableName})`))
824
+ const indexRows = []
825
+ for (const row of indexListRows) {
826
+ if (normalizeText(row.origin).toLowerCase() === 'pk') {
827
+ continue
828
+ }
829
+
830
+ const indexName = normalizeText(row.name)
831
+ const quotedIndexName = quoteSqliteIdentifier(indexName)
832
+ const columnsForIndex = normalizeRows(await knex.raw(`PRAGMA index_info(${quotedIndexName})`))
833
+
834
+ for (const columnRow of columnsForIndex) {
835
+ indexRows.push({
836
+ indexName,
837
+ nonUnique: Number(row.unique) === 1 ? 0 : 1,
838
+ indexType: '',
839
+ columnName: normalizeText(columnRow.name),
840
+ seqInIndex: Number(columnRow.seqno) + 1
841
+ })
842
+ }
843
+ }
844
+
845
+ const foreignKeyRows = normalizeRows(await knex.raw(`PRAGMA foreign_key_list(${quotedTableName})`))
846
+ .map((row) => ({
847
+ groupId: row.id,
848
+ columnName: row.from,
849
+ referencedTableName: row.table,
850
+ referencedColumnName: row.to,
851
+ ordinalPosition: Number(row.seq) + 1,
852
+ updateRule: row.on_update,
853
+ deleteRule: row.on_delete
854
+ }))
855
+
856
+ const resolvedIdColumn = requireIdColumn(columns, idColumn)
857
+ requirePrimaryKeyContainsId(primaryKeyColumns, resolvedIdColumn)
858
+
859
+ return {
860
+ dialect: 'sqlite',
861
+ schemaName: 'main',
862
+ tableName,
863
+ tableCollation: '',
864
+ idColumn: resolvedIdColumn,
865
+ primaryKeyColumns,
866
+ hasWorkspaceIdColumn: columns.some((column) => column.name === 'workspace_id'),
867
+ hasUserIdColumn: columns.some((column) => column.name === 'user_id'),
868
+ columns,
869
+ indexes: normalizeIndexes(indexRows),
870
+ foreignKeys: normalizeForeignKeys(foreignKeyRows, parsedConstraints.foreignKeys, tableName),
871
+ checkConstraints: normalizeCheckConstraints(parsedConstraints.checkConstraints, tableName)
872
+ }
873
+ }
874
+
875
+ function normalizeDbSchemaName (rows = []) {
876
+ const firstRow = Array.isArray(rows) ? rows[0] : null
877
+ const schemaName = normalizeText(firstRow?.schemaName || firstRow?.schema_name)
878
+ if (!schemaName) {
879
+ throw new Error('Could not resolve current database schema name.')
880
+ }
881
+
882
+ return schemaName
883
+ }
884
+
885
+ async function introspectMysqlTableSnapshot (knex, { tableName, idColumn }) {
886
+ const schemaRows = normalizeRows(await knex.raw('SELECT DATABASE() AS schemaName'))
887
+ const schemaName = normalizeDbSchemaName(schemaRows)
888
+
889
+ const tableRows = normalizeRows(
890
+ await knex.raw(
891
+ `
892
+ SELECT
893
+ t.table_collation AS tableCollation
894
+ FROM information_schema.tables t
895
+ WHERE t.table_schema = ?
896
+ AND t.table_name = ?
897
+ LIMIT 1
898
+ `,
899
+ [schemaName, tableName]
900
+ )
901
+ )
902
+
903
+ const columnRows = normalizeRows(
904
+ await knex.raw(
905
+ `
906
+ SELECT
907
+ c.column_name AS columnName,
908
+ c.data_type AS dataType,
909
+ c.column_type AS columnType,
910
+ c.is_nullable AS isNullable,
911
+ c.column_default AS columnDefault,
912
+ c.extra AS extra,
913
+ c.character_maximum_length AS characterMaximumLength,
914
+ c.character_set_name AS characterSetName,
915
+ c.collation_name AS collationName,
916
+ c.numeric_precision AS numericPrecision,
917
+ c.numeric_scale AS numericScale,
918
+ c.datetime_precision AS datetimePrecision,
919
+ c.ordinal_position AS ordinalPosition
920
+ FROM information_schema.columns c
921
+ WHERE c.table_schema = ?
922
+ AND c.table_name = ?
923
+ ORDER BY c.ordinal_position ASC
924
+ `,
925
+ [schemaName, tableName]
926
+ )
927
+ )
928
+ if (columnRows.length < 1) {
929
+ throw new Error(`Could not introspect table "${tableName}" in schema "${schemaName}".`)
930
+ }
931
+
932
+ const primaryRows = normalizeRows(
933
+ await knex.raw(
934
+ `
935
+ SELECT
936
+ k.column_name AS columnName,
937
+ k.ordinal_position AS ordinalPosition
938
+ FROM information_schema.table_constraints t
939
+ JOIN information_schema.key_column_usage k
940
+ ON k.constraint_name = t.constraint_name
941
+ AND k.table_schema = t.table_schema
942
+ AND k.table_name = t.table_name
943
+ WHERE t.table_schema = ?
944
+ AND t.table_name = ?
945
+ AND t.constraint_type = 'PRIMARY KEY'
946
+ ORDER BY k.ordinal_position ASC
947
+ `,
948
+ [schemaName, tableName]
949
+ )
950
+ )
951
+
952
+ const indexRows = normalizeRows(
953
+ await knex.raw(
954
+ `
955
+ SELECT
956
+ s.index_name AS indexName,
957
+ s.non_unique AS nonUnique,
958
+ s.index_type AS indexType,
959
+ s.column_name AS columnName,
960
+ s.seq_in_index AS seqInIndex
961
+ FROM information_schema.statistics s
962
+ WHERE s.table_schema = ?
963
+ AND s.table_name = ?
964
+ AND s.index_name <> 'PRIMARY'
965
+ ORDER BY s.index_name ASC, s.seq_in_index ASC
966
+ `,
967
+ [schemaName, tableName]
968
+ )
969
+ )
970
+
971
+ const foreignKeyRows = normalizeRows(
972
+ await knex.raw(
973
+ `
974
+ SELECT
975
+ rc.constraint_name AS constraintName,
976
+ k.column_name AS columnName,
977
+ k.referenced_table_name AS referencedTableName,
978
+ k.referenced_column_name AS referencedColumnName,
979
+ k.ordinal_position AS ordinalPosition,
980
+ rc.update_rule AS updateRule,
981
+ rc.delete_rule AS deleteRule
982
+ FROM information_schema.referential_constraints rc
983
+ JOIN information_schema.key_column_usage k
984
+ ON k.constraint_name = rc.constraint_name
985
+ AND k.constraint_schema = rc.constraint_schema
986
+ AND k.table_name = rc.table_name
987
+ WHERE rc.constraint_schema = ?
988
+ AND rc.table_name = ?
989
+ ORDER BY rc.constraint_name ASC, k.ordinal_position ASC
990
+ `,
991
+ [schemaName, tableName]
992
+ )
993
+ )
994
+
995
+ const checkConstraintRows = normalizeRows(
996
+ await knex.raw(
997
+ `
998
+ SELECT
999
+ tc.constraint_name AS constraintName,
1000
+ cc.check_clause AS checkClause
1001
+ FROM information_schema.table_constraints tc
1002
+ JOIN information_schema.check_constraints cc
1003
+ ON cc.constraint_schema = tc.constraint_schema
1004
+ AND cc.constraint_name = tc.constraint_name
1005
+ WHERE tc.table_schema = ?
1006
+ AND tc.table_name = ?
1007
+ AND tc.constraint_type = 'CHECK'
1008
+ ORDER BY tc.constraint_name ASC
1009
+ `,
1010
+ [schemaName, tableName]
1011
+ )
1012
+ )
1013
+
1014
+ const columns = columnRows.map((row) => buildColumnSnapshot({
1015
+ name: row.columnName,
1016
+ dataType: row.dataType,
1017
+ columnType: row.columnType,
1018
+ nullable: normalizeText(row.isNullable).toUpperCase() === 'YES',
1019
+ defaultValue: Object.prototype.hasOwnProperty.call(row, 'columnDefault') ? row.columnDefault : row.column_default,
1020
+ extra: row.extra,
1021
+ autoIncrement: normalizeText(row.extra).toLowerCase().includes('auto_increment'),
1022
+ unsigned: normalizeText(row.columnType).toLowerCase().includes('unsigned'),
1023
+ maxLength: row.characterMaximumLength,
1024
+ numericPrecision: row.numericPrecision,
1025
+ numericScale: row.numericScale,
1026
+ datetimePrecision: row.datetimePrecision,
1027
+ characterSetName: row.characterSetName,
1028
+ collationName: row.collationName,
1029
+ ordinalPosition: row.ordinalPosition
1030
+ }))
1031
+
1032
+ const resolvedIdColumn = requireIdColumn(columns, idColumn)
1033
+ const primaryKeyColumns = normalizePrimaryKeyColumns(primaryRows)
1034
+ requirePrimaryKeyContainsId(primaryKeyColumns, resolvedIdColumn)
1035
+
1036
+ return {
1037
+ dialect: 'mysql2',
1038
+ schemaName,
1039
+ tableName,
1040
+ tableCollation: normalizeText((Array.isArray(tableRows) ? tableRows[0] : null)?.tableCollation),
1041
+ idColumn: resolvedIdColumn,
1042
+ primaryKeyColumns,
1043
+ hasWorkspaceIdColumn: columns.some((column) => column.name === 'workspace_id'),
1044
+ hasUserIdColumn: columns.some((column) => column.name === 'user_id'),
1045
+ columns,
1046
+ indexes: normalizeIndexes(indexRows),
1047
+ foreignKeys: normalizeForeignKeys(foreignKeyRows, [], tableName),
1048
+ checkConstraints: normalizeCheckConstraints(
1049
+ checkConstraintRows.map((row) => ({
1050
+ name: row.constraintName,
1051
+ clause: row.checkClause
1052
+ })),
1053
+ tableName
1054
+ )
1055
+ }
1056
+ }
1057
+
1058
+ export async function introspectKnexTableSnapshot (knex, { tableName = '', idColumn = 'id' } = {}) {
1059
+ requireKnexRaw(knex)
1060
+ const resolvedTableName = requireTableName(tableName)
1061
+ const dialect = detectDialect(knex)
1062
+
1063
+ if (dialect === 'sqlite') {
1064
+ return introspectSqliteTableSnapshot(knex, {
1065
+ tableName: resolvedTableName,
1066
+ idColumn
1067
+ })
1068
+ }
1069
+
1070
+ if (dialect === 'mysql2') {
1071
+ return introspectMysqlTableSnapshot(knex, {
1072
+ tableName: resolvedTableName,
1073
+ idColumn
1074
+ })
1075
+ }
1076
+
1077
+ throw new Error(`Unsupported dialect "${dialect}" for introspectKnexTableSnapshot.`)
1078
+ }