@superblocksteam/shared 0.9617.8 → 0.9618.1

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 (178) hide show
  1. package/dist/database-lifecycle/appDatabaseImport.d.ts +42 -7
  2. package/dist/database-lifecycle/appDatabaseImport.d.ts.map +1 -1
  3. package/dist/database-lifecycle/appDatabaseImport.js +66 -5
  4. package/dist/database-lifecycle/appDatabaseImport.js.map +1 -1
  5. package/dist/database-lifecycle/appDatabaseImport.test.js +79 -0
  6. package/dist/database-lifecycle/appDatabaseImport.test.js.map +1 -1
  7. package/dist/database-lifecycle/appDatabaseStatus.d.ts +18 -0
  8. package/dist/database-lifecycle/appDatabaseStatus.d.ts.map +1 -1
  9. package/dist/database-lifecycle/appDatabaseStatus.js +21 -0
  10. package/dist/database-lifecycle/appDatabaseStatus.js.map +1 -1
  11. package/dist/database-lifecycle/appDatabaseStatus.test.js +11 -0
  12. package/dist/database-lifecycle/appDatabaseStatus.test.js.map +1 -1
  13. package/dist/database-lifecycle/dataImport.d.ts +45 -2
  14. package/dist/database-lifecycle/dataImport.d.ts.map +1 -1
  15. package/dist/database-lifecycle/dataImport.js +209 -3
  16. package/dist/database-lifecycle/dataImport.js.map +1 -1
  17. package/dist/database-lifecycle/dataImport.test.js +60 -0
  18. package/dist/database-lifecycle/dataImport.test.js.map +1 -1
  19. package/dist/database-lifecycle/importCompatibility.d.ts +40 -0
  20. package/dist/database-lifecycle/importCompatibility.d.ts.map +1 -0
  21. package/dist/database-lifecycle/importCompatibility.js +121 -0
  22. package/dist/database-lifecycle/importCompatibility.js.map +1 -0
  23. package/dist/database-lifecycle/importCompatibility.test.d.ts +2 -0
  24. package/dist/database-lifecycle/importCompatibility.test.d.ts.map +1 -0
  25. package/dist/database-lifecycle/importCompatibility.test.js +114 -0
  26. package/dist/database-lifecycle/importCompatibility.test.js.map +1 -0
  27. package/dist/database-lifecycle/index.d.ts +29 -5
  28. package/dist/database-lifecycle/index.d.ts.map +1 -1
  29. package/dist/database-lifecycle/index.js +18 -5
  30. package/dist/database-lifecycle/index.js.map +1 -1
  31. package/dist/database-lifecycle/index.test.js +4 -1
  32. package/dist/database-lifecycle/index.test.js.map +1 -1
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/types/audit/ocsf.d.ts +2 -1
  38. package/dist/types/audit/ocsf.d.ts.map +1 -1
  39. package/dist/types/audit/ocsf.js +8 -0
  40. package/dist/types/audit/ocsf.js.map +1 -1
  41. package/dist/types/codeSecurity/index.d.ts +2 -1
  42. package/dist/types/codeSecurity/index.d.ts.map +1 -1
  43. package/dist/types/codeSecurity/index.js +43 -5
  44. package/dist/types/codeSecurity/index.js.map +1 -1
  45. package/dist/types/codeSecurity/index.test.js +37 -3
  46. package/dist/types/codeSecurity/index.test.js.map +1 -1
  47. package/dist/types/datasource/auth.d.ts +1 -0
  48. package/dist/types/datasource/auth.d.ts.map +1 -1
  49. package/dist/types/datasource/auth.js +8 -2
  50. package/dist/types/datasource/auth.js.map +1 -1
  51. package/dist/types/datasource/auth.test.js +19 -1
  52. package/dist/types/datasource/auth.test.js.map +1 -1
  53. package/dist/types/integration/index.d.ts +16 -1
  54. package/dist/types/integration/index.d.ts.map +1 -1
  55. package/dist/types/integration/index.js +8 -1
  56. package/dist/types/integration/index.js.map +1 -1
  57. package/dist/types/policyGate/index.d.ts +1 -0
  58. package/dist/types/policyGate/index.d.ts.map +1 -1
  59. package/dist/types/policyGate/index.js +3 -1
  60. package/dist/types/policyGate/index.js.map +1 -1
  61. package/dist/types/publishPipeline/index.d.ts +2 -0
  62. package/dist/types/publishPipeline/index.d.ts.map +1 -1
  63. package/dist/types/publishPipeline/index.js.map +1 -1
  64. package/dist/types/publishPipeline/plan.d.ts +1 -1
  65. package/dist/types/publishPipeline/plan.d.ts.map +1 -1
  66. package/dist/types/publishPipeline/plan.js.map +1 -1
  67. package/dist/types/rbac/index.d.ts +0 -5
  68. package/dist/types/rbac/index.d.ts.map +1 -1
  69. package/dist/types/rbac/index.js.map +1 -1
  70. package/dist/types/response/index.d.ts +0 -1
  71. package/dist/types/response/index.d.ts.map +1 -1
  72. package/dist/types/response/index.js +0 -1
  73. package/dist/types/response/index.js.map +1 -1
  74. package/dist-esm/database-lifecycle/appDatabaseImport.d.ts +42 -7
  75. package/dist-esm/database-lifecycle/appDatabaseImport.d.ts.map +1 -1
  76. package/dist-esm/database-lifecycle/appDatabaseImport.js +65 -5
  77. package/dist-esm/database-lifecycle/appDatabaseImport.js.map +1 -1
  78. package/dist-esm/database-lifecycle/appDatabaseImport.test.js +79 -0
  79. package/dist-esm/database-lifecycle/appDatabaseImport.test.js.map +1 -1
  80. package/dist-esm/database-lifecycle/appDatabaseStatus.d.ts +18 -0
  81. package/dist-esm/database-lifecycle/appDatabaseStatus.d.ts.map +1 -1
  82. package/dist-esm/database-lifecycle/appDatabaseStatus.js +18 -0
  83. package/dist-esm/database-lifecycle/appDatabaseStatus.js.map +1 -1
  84. package/dist-esm/database-lifecycle/appDatabaseStatus.test.js +12 -1
  85. package/dist-esm/database-lifecycle/appDatabaseStatus.test.js.map +1 -1
  86. package/dist-esm/database-lifecycle/dataImport.d.ts +45 -2
  87. package/dist-esm/database-lifecycle/dataImport.d.ts.map +1 -1
  88. package/dist-esm/database-lifecycle/dataImport.js +205 -3
  89. package/dist-esm/database-lifecycle/dataImport.js.map +1 -1
  90. package/dist-esm/database-lifecycle/dataImport.test.js +61 -1
  91. package/dist-esm/database-lifecycle/dataImport.test.js.map +1 -1
  92. package/dist-esm/database-lifecycle/importCompatibility.d.ts +40 -0
  93. package/dist-esm/database-lifecycle/importCompatibility.d.ts.map +1 -0
  94. package/dist-esm/database-lifecycle/importCompatibility.js +117 -0
  95. package/dist-esm/database-lifecycle/importCompatibility.js.map +1 -0
  96. package/dist-esm/database-lifecycle/importCompatibility.test.d.ts +2 -0
  97. package/dist-esm/database-lifecycle/importCompatibility.test.d.ts.map +1 -0
  98. package/dist-esm/database-lifecycle/importCompatibility.test.js +112 -0
  99. package/dist-esm/database-lifecycle/importCompatibility.test.js.map +1 -0
  100. package/dist-esm/database-lifecycle/index.d.ts +29 -5
  101. package/dist-esm/database-lifecycle/index.d.ts.map +1 -1
  102. package/dist-esm/database-lifecycle/index.js +13 -5
  103. package/dist-esm/database-lifecycle/index.js.map +1 -1
  104. package/dist-esm/database-lifecycle/index.test.js +4 -1
  105. package/dist-esm/database-lifecycle/index.test.js.map +1 -1
  106. package/dist-esm/index.d.ts +1 -0
  107. package/dist-esm/index.d.ts.map +1 -1
  108. package/dist-esm/index.js +1 -0
  109. package/dist-esm/index.js.map +1 -1
  110. package/dist-esm/types/audit/ocsf.d.ts +2 -1
  111. package/dist-esm/types/audit/ocsf.d.ts.map +1 -1
  112. package/dist-esm/types/audit/ocsf.js +8 -0
  113. package/dist-esm/types/audit/ocsf.js.map +1 -1
  114. package/dist-esm/types/codeSecurity/index.d.ts +2 -1
  115. package/dist-esm/types/codeSecurity/index.d.ts.map +1 -1
  116. package/dist-esm/types/codeSecurity/index.js +43 -5
  117. package/dist-esm/types/codeSecurity/index.js.map +1 -1
  118. package/dist-esm/types/codeSecurity/index.test.js +37 -3
  119. package/dist-esm/types/codeSecurity/index.test.js.map +1 -1
  120. package/dist-esm/types/datasource/auth.d.ts +1 -0
  121. package/dist-esm/types/datasource/auth.d.ts.map +1 -1
  122. package/dist-esm/types/datasource/auth.js +7 -1
  123. package/dist-esm/types/datasource/auth.js.map +1 -1
  124. package/dist-esm/types/datasource/auth.test.js +20 -2
  125. package/dist-esm/types/datasource/auth.test.js.map +1 -1
  126. package/dist-esm/types/integration/index.d.ts +16 -1
  127. package/dist-esm/types/integration/index.d.ts.map +1 -1
  128. package/dist-esm/types/integration/index.js +8 -1
  129. package/dist-esm/types/integration/index.js.map +1 -1
  130. package/dist-esm/types/policyGate/index.d.ts +1 -0
  131. package/dist-esm/types/policyGate/index.d.ts.map +1 -1
  132. package/dist-esm/types/policyGate/index.js +2 -0
  133. package/dist-esm/types/policyGate/index.js.map +1 -1
  134. package/dist-esm/types/publishPipeline/index.d.ts +2 -0
  135. package/dist-esm/types/publishPipeline/index.d.ts.map +1 -1
  136. package/dist-esm/types/publishPipeline/index.js.map +1 -1
  137. package/dist-esm/types/publishPipeline/plan.d.ts +1 -1
  138. package/dist-esm/types/publishPipeline/plan.d.ts.map +1 -1
  139. package/dist-esm/types/publishPipeline/plan.js.map +1 -1
  140. package/dist-esm/types/rbac/index.d.ts +0 -5
  141. package/dist-esm/types/rbac/index.d.ts.map +1 -1
  142. package/dist-esm/types/rbac/index.js.map +1 -1
  143. package/dist-esm/types/response/index.d.ts +0 -1
  144. package/dist-esm/types/response/index.d.ts.map +1 -1
  145. package/dist-esm/types/response/index.js +0 -1
  146. package/dist-esm/types/response/index.js.map +1 -1
  147. package/package.json +2 -2
  148. package/src/database-lifecycle/appDatabaseImport.test.ts +95 -0
  149. package/src/database-lifecycle/appDatabaseImport.ts +134 -8
  150. package/src/database-lifecycle/appDatabaseStatus.test.ts +20 -1
  151. package/src/database-lifecycle/appDatabaseStatus.ts +39 -0
  152. package/src/database-lifecycle/dataImport.test.ts +75 -0
  153. package/src/database-lifecycle/dataImport.ts +267 -4
  154. package/src/database-lifecycle/importCompatibility.test.ts +146 -0
  155. package/src/database-lifecycle/importCompatibility.ts +148 -0
  156. package/src/database-lifecycle/index.test.ts +4 -1
  157. package/src/database-lifecycle/index.ts +54 -4
  158. package/src/index.ts +1 -0
  159. package/src/types/audit/ocsf.ts +10 -1
  160. package/src/types/codeSecurity/index.test.ts +40 -3
  161. package/src/types/codeSecurity/index.ts +45 -4
  162. package/src/types/datasource/auth.test.ts +25 -1
  163. package/src/types/datasource/auth.ts +9 -1
  164. package/src/types/integration/index.ts +17 -0
  165. package/src/types/policyGate/index.ts +3 -0
  166. package/src/types/publishPipeline/index.ts +2 -0
  167. package/src/types/publishPipeline/plan.ts +1 -1
  168. package/src/types/rbac/index.ts +0 -8
  169. package/src/types/response/index.ts +0 -1
  170. package/dist/types/response/GetGroupPermissionEntitiesResponseBody.d.ts +0 -3
  171. package/dist/types/response/GetGroupPermissionEntitiesResponseBody.d.ts.map +0 -1
  172. package/dist/types/response/GetGroupPermissionEntitiesResponseBody.js +0 -3
  173. package/dist/types/response/GetGroupPermissionEntitiesResponseBody.js.map +0 -1
  174. package/dist-esm/types/response/GetGroupPermissionEntitiesResponseBody.d.ts +0 -3
  175. package/dist-esm/types/response/GetGroupPermissionEntitiesResponseBody.d.ts.map +0 -1
  176. package/dist-esm/types/response/GetGroupPermissionEntitiesResponseBody.js +0 -2
  177. package/dist-esm/types/response/GetGroupPermissionEntitiesResponseBody.js.map +0 -1
  178. package/src/types/response/GetGroupPermissionEntitiesResponseBody.ts +0 -3
@@ -13,6 +13,8 @@ export const DATA_IMPORT_STATES = [
13
13
  'validating',
14
14
  'validating_schema',
15
15
  'schema_validated',
16
+ 'capturing_source',
17
+ 'restoring',
16
18
  'ready',
17
19
  'importing',
18
20
  'completed',
@@ -60,19 +62,51 @@ export type DataImportUploadMetadata = {
60
62
  // signed URLs, credentials, ETags, and row samples stay out.
61
63
  export type PgDumpDataImportMetadata = {
62
64
  kind: 'pg_dump_custom';
65
+ capturedObjectKey?: string;
66
+ capturedSha256?: string;
67
+ capturedSizeBytes?: number;
63
68
  currentPrefixSizeBytes?: number;
64
69
  postgresMajorVersion?: number;
65
70
  prefixChecksumSha256?: string;
66
71
  prefixObjectKey?: string;
67
72
  requestedPrefixSizeBytes?: number;
73
+ source?: {
74
+ integrationConfigurationId: string;
75
+ integrationId: string;
76
+ profile: string;
77
+ profileId: string;
78
+ type: 'integration';
79
+ };
68
80
  tableNames?: string[];
69
81
  };
70
82
 
83
+ export type CsvColumnMapping = {
84
+ source: string;
85
+ target: string;
86
+ };
87
+
88
+ // Settings the table-import worker requires before it COPY-loads one CSV.
89
+ // Absent on uploads that only store the file.
90
+ export type CsvTableImportPlan = {
91
+ columnMapping: CsvColumnMapping[];
92
+ dateStyle: string;
93
+ delimiter: ',' | ';' | '\t' | '|';
94
+ emptyAsNull: boolean;
95
+ encoding: 'UTF8' | 'WINDOWS1252';
96
+ hasHeader: boolean;
97
+ timeZone: string;
98
+ trimWhitespace: boolean;
99
+ };
100
+
71
101
  export type CsvDataImportMetadata = {
72
102
  kind: 'csv';
73
103
  mode: 'append' | 'replace';
74
104
  table: string;
75
- };
105
+ } & Partial<CsvTableImportPlan>;
106
+
107
+ export type CsvImportPlanParse = { message: string; status: 'invalid' } | { plan: CsvTableImportPlan; status: 'ok' };
108
+
109
+ export type CsvTableImportPlanPresence = { status: 'absent' } | CsvImportPlanParse;
76
110
 
77
111
  export type DataImportMetadata = PgDumpDataImportMetadata | CsvDataImportMetadata;
78
112
 
@@ -122,14 +156,119 @@ export function parseDataImportMetadata(kind: DataImportKind, value: unknown): D
122
156
  return parsePgDumpMetadata(value);
123
157
  }
124
158
  const table = canonicalCsvTable(value.table);
125
- if (table === undefined || (value.mode !== 'append' && value.mode !== 'replace')) {
159
+ if (table === undefined || (value.mode !== 'append' && value.mode !== 'replace') || isReservedCsvImportTable(table)) {
126
160
  return undefined;
127
161
  }
128
- const allowed = new Set(['kind', 'mode', 'table']);
162
+ const plan = parseCsvTableImportPlanFields(value);
163
+ if (plan.status === 'invalid') {
164
+ return undefined;
165
+ }
166
+ const allowed = new Set<string>(['kind', 'mode', 'table', ...(plan.status === 'ok' ? CSV_TABLE_IMPORT_PLAN_KEYS : [])]);
129
167
  if (Object.keys(value).some((key) => !allowed.has(key))) {
130
168
  return undefined;
131
169
  }
132
- return { kind: 'csv', mode: value.mode, table };
170
+ if (plan.status === 'absent') {
171
+ return { kind: 'csv', mode: value.mode, table };
172
+ }
173
+ return { kind: 'csv', mode: value.mode, table, ...plan.plan };
174
+ }
175
+
176
+ export function csvTableImportPlan(metadata: CsvDataImportMetadata): CsvTableImportPlan | undefined {
177
+ if (
178
+ metadata.columnMapping === undefined ||
179
+ metadata.dateStyle === undefined ||
180
+ metadata.delimiter === undefined ||
181
+ metadata.emptyAsNull === undefined ||
182
+ metadata.encoding === undefined ||
183
+ metadata.hasHeader === undefined ||
184
+ metadata.timeZone === undefined ||
185
+ metadata.trimWhitespace === undefined
186
+ ) {
187
+ return undefined;
188
+ }
189
+ return {
190
+ columnMapping: metadata.columnMapping,
191
+ dateStyle: metadata.dateStyle,
192
+ delimiter: metadata.delimiter,
193
+ emptyAsNull: metadata.emptyAsNull,
194
+ encoding: metadata.encoding,
195
+ hasHeader: metadata.hasHeader,
196
+ timeZone: metadata.timeZone,
197
+ trimWhitespace: metadata.trimWhitespace
198
+ };
199
+ }
200
+
201
+ // The migrations ledger and system catalogs are not import targets.
202
+ export function isReservedCsvImportTable(table: string): boolean {
203
+ const separator = table.indexOf('.');
204
+ if (separator < 1) {
205
+ return false;
206
+ }
207
+ const schema = table.slice(0, separator);
208
+ const name = table.slice(separator + 1);
209
+ if (schema === 'information_schema' || schema.startsWith('pg_')) {
210
+ return true;
211
+ }
212
+ return schema === 'public' && name === 'superblocks_schema_migrations';
213
+ }
214
+
215
+ const CSV_DATE_FORMAT_TO_DATE_STYLE: Record<string, string> = {
216
+ 'DD.MM.YYYY': 'German, DMY',
217
+ 'DD/MM/YYYY': 'ISO, DMY',
218
+ 'MM/DD/YYYY': 'ISO, MDY',
219
+ 'YYYY-MM-DD': 'ISO, YMD'
220
+ };
221
+
222
+ // Wizard spellings become the worker's DateStyle, encoding, and column list.
223
+ // emptyAsNull stays on, the delimiter stays a comma, and the session time zone
224
+ // stays UTC. The first row is always a header.
225
+ export function csvImportPlanFromWizard(input: {
226
+ columnMappings: Record<string, string>;
227
+ dateFormat: string;
228
+ encoding: string;
229
+ trimWhitespace: boolean;
230
+ }): CsvImportPlanParse {
231
+ const encoding = workerCsvEncoding(input.encoding);
232
+ if (!encoding) {
233
+ return { message: 'Encoding must be UTF-8 or Windows-1252.', status: 'invalid' };
234
+ }
235
+ const dateStyle = workerDateStyle(input.dateFormat);
236
+ if (!dateStyle) {
237
+ return {
238
+ message: 'Date format must be YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY, or DD.MM.YYYY.',
239
+ status: 'invalid'
240
+ };
241
+ }
242
+ const columnMapping = columnMappingsFromWizard(input.columnMappings);
243
+ if (columnMapping.status === 'invalid') {
244
+ return columnMapping;
245
+ }
246
+ return readCsvTableImportPlan({
247
+ columnMapping: columnMapping.mapping,
248
+ dateStyle,
249
+ delimiter: ',',
250
+ emptyAsNull: true,
251
+ encoding,
252
+ hasHeader: true,
253
+ timeZone: 'UTC',
254
+ trimWhitespace: input.trimWhitespace
255
+ });
256
+ }
257
+
258
+ export function parseCsvTableImportPlanFields(value: Record<string, unknown>): CsvTableImportPlanPresence {
259
+ if (!CSV_TABLE_IMPORT_PLAN_KEYS.some((key) => value[key] !== undefined)) {
260
+ return { status: 'absent' };
261
+ }
262
+ return readCsvTableImportPlan({
263
+ columnMapping: value.columnMapping,
264
+ dateStyle: value.dateStyle,
265
+ delimiter: value.delimiter,
266
+ emptyAsNull: value.emptyAsNull,
267
+ encoding: value.encoding,
268
+ hasHeader: value.hasHeader,
269
+ timeZone: value.timeZone,
270
+ trimWhitespace: value.trimWhitespace
271
+ });
133
272
  }
134
273
 
135
274
  export function parseDataImportOutcome(value: unknown): DataImportOutcome | undefined {
@@ -235,6 +374,130 @@ function assignOptionalInteger(
235
374
  return true;
236
375
  }
237
376
 
377
+ const CSV_TABLE_IMPORT_PLAN_KEYS = [
378
+ 'columnMapping',
379
+ 'dateStyle',
380
+ 'delimiter',
381
+ 'emptyAsNull',
382
+ 'encoding',
383
+ 'hasHeader',
384
+ 'timeZone',
385
+ 'trimWhitespace'
386
+ ] as const;
387
+
388
+ const CSV_DATE_STYLE = /^(ISO|SQL|Postgres|German)(, (MDY|DMY|YMD))?$/;
389
+ const CSV_TIME_ZONE = /^[A-Za-z0-9_+-]{1,64}(?:\/[A-Za-z0-9_+-]{1,64}){0,2}$/;
390
+ const CSV_TARGET_COLUMN = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
391
+ const CSV_DELIMITERS = [',', ';', '\t', '|'] as const;
392
+
393
+ function workerCsvEncoding(value: string): 'UTF8' | 'WINDOWS1252' | undefined {
394
+ const normalized = value.trim().toUpperCase().replace(/-/g, '');
395
+ if (normalized === 'UTF8') {
396
+ return 'UTF8';
397
+ }
398
+ if (normalized === 'WINDOWS1252' || normalized === 'CP1252') {
399
+ return 'WINDOWS1252';
400
+ }
401
+ return undefined;
402
+ }
403
+
404
+ function workerDateStyle(value: string): string | undefined {
405
+ const trimmed = value.trim();
406
+ return CSV_DATE_FORMAT_TO_DATE_STYLE[trimmed] ?? (CSV_DATE_STYLE.test(trimmed) ? trimmed : undefined);
407
+ }
408
+
409
+ function columnMappingsFromWizard(
410
+ mappings: Record<string, string>
411
+ ): { mapping: CsvColumnMapping[]; status: 'ok' } | { message: string; status: 'invalid' } {
412
+ const columnMapping: CsvColumnMapping[] = [];
413
+ const targets = new Set<string>();
414
+ for (const [source, target] of Object.entries(mappings)) {
415
+ const trimmedTarget = target.trim();
416
+ if (trimmedTarget.length === 0) {
417
+ continue;
418
+ }
419
+ const trimmedSource = source.trim();
420
+ if (trimmedSource.length === 0 || trimmedSource.length > 256) {
421
+ return { message: 'CSV column names must be 1 to 256 characters.', status: 'invalid' };
422
+ }
423
+ if (!CSV_TARGET_COLUMN.test(trimmedTarget)) {
424
+ return { message: 'Table column names must be identifiers.', status: 'invalid' };
425
+ }
426
+ const folded = trimmedTarget.toLowerCase();
427
+ if (targets.has(folded)) {
428
+ return { message: 'Each table column can only be mapped once.', status: 'invalid' };
429
+ }
430
+ targets.add(folded);
431
+ columnMapping.push({ source: trimmedSource, target: trimmedTarget });
432
+ }
433
+ columnMapping.sort((left, right) => (left.source < right.source ? -1 : left.source > right.source ? 1 : 0));
434
+ if (columnMapping.length === 0) {
435
+ return { message: 'Map at least one CSV column to a table column.', status: 'invalid' };
436
+ }
437
+ return { mapping: columnMapping, status: 'ok' };
438
+ }
439
+
440
+ function readCsvTableImportPlan(value: {
441
+ columnMapping: unknown;
442
+ dateStyle: unknown;
443
+ delimiter: unknown;
444
+ emptyAsNull: unknown;
445
+ encoding: unknown;
446
+ hasHeader: unknown;
447
+ timeZone: unknown;
448
+ trimWhitespace: unknown;
449
+ }): CsvImportPlanParse {
450
+ if (!Array.isArray(value.columnMapping) || value.columnMapping.length === 0) {
451
+ return { message: 'Map at least one CSV column to a table column.', status: 'invalid' };
452
+ }
453
+ const columnMapping: CsvColumnMapping[] = [];
454
+ const targets = new Set<string>();
455
+ for (const entry of value.columnMapping) {
456
+ if (!isUnknownRecord(entry) || typeof entry.source !== 'string' || typeof entry.target !== 'string') {
457
+ return { message: 'CSV import settings are incomplete or not supported.', status: 'invalid' };
458
+ }
459
+ const source = entry.source.trim();
460
+ const target = entry.target.trim();
461
+ if (source.length === 0 || source.length > 256 || !CSV_TARGET_COLUMN.test(target)) {
462
+ return { message: 'CSV import settings are incomplete or not supported.', status: 'invalid' };
463
+ }
464
+ const folded = target.toLowerCase();
465
+ if (targets.has(folded)) {
466
+ return { message: 'Each table column can only be mapped once.', status: 'invalid' };
467
+ }
468
+ targets.add(folded);
469
+ columnMapping.push({ source, target });
470
+ }
471
+ if (typeof value.dateStyle !== 'string' || !CSV_DATE_STYLE.test(value.dateStyle)) {
472
+ return { message: 'Date format must be YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY, or DD.MM.YYYY.', status: 'invalid' };
473
+ }
474
+ if (typeof value.delimiter !== 'string' || !CSV_DELIMITERS.includes(value.delimiter as (typeof CSV_DELIMITERS)[number])) {
475
+ return { message: 'CSV import settings are incomplete or not supported.', status: 'invalid' };
476
+ }
477
+ if (value.encoding !== 'UTF8' && value.encoding !== 'WINDOWS1252') {
478
+ return { message: 'Encoding must be UTF-8 or Windows-1252.', status: 'invalid' };
479
+ }
480
+ if (typeof value.timeZone !== 'string' || !CSV_TIME_ZONE.test(value.timeZone)) {
481
+ return { message: 'CSV import settings are incomplete or not supported.', status: 'invalid' };
482
+ }
483
+ if (typeof value.emptyAsNull !== 'boolean' || typeof value.hasHeader !== 'boolean' || typeof value.trimWhitespace !== 'boolean') {
484
+ return { message: 'CSV import settings are incomplete or not supported.', status: 'invalid' };
485
+ }
486
+ return {
487
+ plan: {
488
+ columnMapping,
489
+ dateStyle: value.dateStyle,
490
+ delimiter: value.delimiter as CsvTableImportPlan['delimiter'],
491
+ emptyAsNull: value.emptyAsNull,
492
+ encoding: value.encoding,
493
+ hasHeader: value.hasHeader,
494
+ timeZone: value.timeZone,
495
+ trimWhitespace: value.trimWhitespace
496
+ },
497
+ status: 'ok'
498
+ };
499
+ }
500
+
238
501
  function isDecimalCount(value: unknown): value is string {
239
502
  return typeof value === 'string' && /^(0|[1-9][0-9]*)$/.test(value);
240
503
  }
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { AgentStatus } from '../types/organization/agent.js';
4
+ import { DATABASE_LIFECYCLE_TAG_IMPORT_TRANSFORMS, evaluateAppDatabaseImportTag } from './importCompatibility.js';
5
+
6
+ const IMPORT_OPERATIONS = ['complete_import_upload', 'describe_schema', 'import_table_data', 'prepare_import_upload'];
7
+
8
+ function agent({
9
+ agentName,
10
+ operations = IMPORT_OPERATIONS,
11
+ profiles = ['production'],
12
+ status = AgentStatus.ACTIVE,
13
+ transforms = ['empty_as_null', 'trim_whitespace']
14
+ }: {
15
+ agentName?: string;
16
+ operations?: string[];
17
+ profiles?: string[];
18
+ status?: AgentStatus | string;
19
+ transforms?: string[];
20
+ } = {}) {
21
+ return {
22
+ status,
23
+ tags: {
24
+ ...(agentName === undefined ? {} : { 'databaseLifecycle:agentName': [agentName] }),
25
+ 'databaseLifecycle:operations': operations,
26
+ [DATABASE_LIFECYCLE_TAG_IMPORT_TRANSFORMS]: transforms,
27
+ profile: profiles
28
+ }
29
+ };
30
+ }
31
+
32
+ describe('evaluateAppDatabaseImportTag', () => {
33
+ it('offers import and every advertised transform when one agent can run it', () => {
34
+ expect(evaluateAppDatabaseImportTag({ agents: [agent()], profileKey: 'production' })).toEqual({
35
+ availableTransforms: ['empty_as_null', 'trim_whitespace'],
36
+ missingOperations: [],
37
+ profileKey: 'production',
38
+ reason: '',
39
+ supportState: 'supported'
40
+ });
41
+ });
42
+
43
+ it('ignores a worker that does not own the data tag', () => {
44
+ const support = evaluateAppDatabaseImportTag({
45
+ agentName: 'owner',
46
+ agents: [agent({ agentName: 'other' }), agent({ agentName: 'owner', operations: ['describe_schema'] })],
47
+ profileKey: 'production'
48
+ });
49
+
50
+ expect(support.supportState).toBe('unsupported');
51
+ expect(support.missingOperations).toEqual(['complete_import_upload', 'import_table_data', 'prepare_import_upload']);
52
+ });
53
+
54
+ it('keeps a transform the owner advertises when another worker omits it', () => {
55
+ const support = evaluateAppDatabaseImportTag({
56
+ agentName: 'owner',
57
+ agents: [agent({ agentName: 'other', transforms: ['empty_as_null'] }), agent({ agentName: 'owner' })],
58
+ profileKey: 'production'
59
+ });
60
+
61
+ expect(support.supportState).toBe('supported');
62
+ expect(support.availableTransforms).toEqual(['empty_as_null', 'trim_whitespace']);
63
+ });
64
+
65
+ it('waits when the owning worker is not connected', () => {
66
+ const support = evaluateAppDatabaseImportTag({
67
+ agentName: 'owner',
68
+ agents: [agent({ agentName: 'other' })],
69
+ profileKey: 'production'
70
+ });
71
+
72
+ expect(support.supportState).toBe('temporarily_unavailable');
73
+ expect(support.reason).toContain('Its worker is not connected');
74
+ });
75
+
76
+ it('disables CSV when the worker does not advertise empty_as_null', () => {
77
+ const support = evaluateAppDatabaseImportTag({
78
+ agents: [agent({ transforms: ['trim_whitespace'] })],
79
+ profileKey: 'production'
80
+ });
81
+
82
+ expect(support.supportState).toBe('unsupported');
83
+ expect(support.availableTransforms).toEqual([]);
84
+ expect(support.reason).toContain('empty_as_null');
85
+ });
86
+
87
+ it('omits trim when a replica that can claim the import does not advertise it', () => {
88
+ const support = evaluateAppDatabaseImportTag({
89
+ agentName: 'owner',
90
+ agents: [
91
+ agent({ agentName: 'owner' }),
92
+ agent({
93
+ agentName: 'owner',
94
+ operations: ['import_table_data'],
95
+ transforms: ['empty_as_null']
96
+ })
97
+ ],
98
+ profileKey: 'production'
99
+ });
100
+
101
+ expect(support.supportState).toBe('supported');
102
+ expect(support.availableTransforms).toEqual(['empty_as_null']);
103
+ });
104
+
105
+ it('disables CSV when a replica that can claim the import omits empty_as_null', () => {
106
+ const support = evaluateAppDatabaseImportTag({
107
+ agentName: 'owner',
108
+ agents: [agent({ agentName: 'owner' }), agent({ agentName: 'owner', operations: ['import_table_data'], transforms: [] })],
109
+ profileKey: 'production'
110
+ });
111
+
112
+ expect(support.supportState).toBe('unsupported');
113
+ expect(support.availableTransforms).toEqual([]);
114
+ expect(support.reason).toContain('empty_as_null');
115
+ });
116
+
117
+ it('leaves out a transform that any capable agent does not advertise', () => {
118
+ const support = evaluateAppDatabaseImportTag({
119
+ agents: [agent(), agent({ transforms: ['empty_as_null'] })],
120
+ profileKey: 'production'
121
+ });
122
+
123
+ expect(support.supportState).toBe('supported');
124
+ expect(support.availableTransforms).toEqual(['empty_as_null']);
125
+ });
126
+
127
+ it('disables a tag whose connected agent is missing an import operation', () => {
128
+ const support = evaluateAppDatabaseImportTag({
129
+ agents: [agent({ operations: ['describe_schema', 'ensure_database', 'migrate_schema', 'prepare_import_upload'] })],
130
+ profileKey: 'production'
131
+ });
132
+
133
+ expect(support.supportState).toBe('unsupported');
134
+ expect(support.missingOperations).toEqual(['complete_import_upload', 'import_table_data']);
135
+ expect(support.reason).toContain('does not advertise the import operations');
136
+ expect(support.availableTransforms).toEqual([]);
137
+ });
138
+
139
+ it('reports that the check could not tell when no agent is connected', () => {
140
+ expect(evaluateAppDatabaseImportTag({ agents: undefined, profileKey: 'production' }).supportState).toBe('temporarily_unavailable');
141
+ expect(evaluateAppDatabaseImportTag({ agents: [], profileKey: 'production' }).supportState).toBe('temporarily_unavailable');
142
+ expect(
143
+ evaluateAppDatabaseImportTag({ agents: [agent({ status: AgentStatus.DISCONNECTED })], profileKey: 'production' }).supportState
144
+ ).toBe('temporarily_unavailable');
145
+ });
146
+ });
@@ -0,0 +1,148 @@
1
+ import { AgentStatus } from '../types/organization/agent.js';
2
+ import type { AppDatabaseTagSupportState } from './appDatabaseCompatibility.js';
3
+ import type { AgentLifecycleTagSource } from './edit-lifecycle-capability.js';
4
+ import { DATABASE_LIFECYCLE_TAG_AGENT_NAME } from './physicalPoolIdentity.js';
5
+
6
+ export const DATABASE_LIFECYCLE_TAG_IMPORT_TRANSFORMS = 'databaseLifecycle:importTransforms';
7
+
8
+ /**
9
+ * Operations a data tag needs before CSV import can be offered.
10
+ *
11
+ * Kept off {@link APP_DATABASE_REQUIRED_OPERATIONS}. Missing one of these
12
+ * disables import. It must not make the app undeployable.
13
+ */
14
+ export const APP_DATABASE_IMPORT_REQUIRED_OPERATIONS = ['complete_import_upload', 'import_table_data', 'prepare_import_upload'] as const;
15
+
16
+ export const APP_DATABASE_IMPORT_TRANSFORMS = ['empty_as_null', 'trim_whitespace'] as const;
17
+ export type AppDatabaseImportTransform = (typeof APP_DATABASE_IMPORT_TRANSFORMS)[number];
18
+
19
+ export type AppDatabaseImportTagSupport = {
20
+ availableTransforms: AppDatabaseImportTransform[];
21
+ missingOperations: string[];
22
+ profileKey: string;
23
+ reason: string;
24
+ supportState: AppDatabaseTagSupportState;
25
+ };
26
+
27
+ const IMPORT_UNAVAILABLE_REASON =
28
+ 'CSV import is not available for this data tag. The connected agent does not advertise the import operations.';
29
+ const IMPORT_EMPTY_AS_NULL_REASON = 'CSV import is not available for this data tag. The connected agent does not advertise empty_as_null.';
30
+ const IMPORT_OWNER_DISCONNECTED_REASON = 'CSV import is not available for this data tag. Its worker is not connected.';
31
+ const IMPORT_UNKNOWN_REASON = 'CSV import could not be checked. No connected agent has published its capabilities.';
32
+
33
+ /**
34
+ * Whether one data tag can accept a CSV import, from the agents connected now.
35
+ *
36
+ * `unsupported` is a standing gap. `temporarily_unavailable` means nothing
37
+ * connected could be read, so the tag may start working without an app change.
38
+ * When `agentName` is set, only the worker that owns the binding counts.
39
+ * Replicas that publish that same name stay together. Schema description is an
40
+ * HTTP route, so `describe_schema` is not required here.
41
+ *
42
+ * The wizard always sends `empty_as_null`. CSV stays off unless every replica
43
+ * that can claim `import_table_data` advertises it. Optional transforms are
44
+ * the intersection of those same replicas, including one that can claim the
45
+ * copy while missing an upload operation. The server only hands a replica a
46
+ * dispatch for an operation it advertises.
47
+ */
48
+ export function evaluateAppDatabaseImportTag({
49
+ agentName,
50
+ agents,
51
+ profileKey
52
+ }: {
53
+ agentName?: string | null;
54
+ agents: readonly AgentLifecycleTagSource[] | undefined;
55
+ profileKey: string;
56
+ }): AppDatabaseImportTagSupport {
57
+ const active = (agents ?? []).filter(isActiveAgent);
58
+ if (agents === undefined || active.length === 0) {
59
+ return {
60
+ availableTransforms: [],
61
+ missingOperations: [...APP_DATABASE_IMPORT_REQUIRED_OPERATIONS],
62
+ profileKey,
63
+ reason: IMPORT_UNKNOWN_REASON,
64
+ supportState: 'temporarily_unavailable'
65
+ };
66
+ }
67
+ const owner = agentName?.trim() ?? '';
68
+ const counted = owner === '' ? active : active.filter((agent) => publishesAgentName(agent, owner));
69
+ if (owner !== '' && counted.length === 0) {
70
+ return {
71
+ availableTransforms: [],
72
+ missingOperations: [...APP_DATABASE_IMPORT_REQUIRED_OPERATIONS],
73
+ profileKey,
74
+ reason: IMPORT_OWNER_DISCONNECTED_REASON,
75
+ supportState: 'temporarily_unavailable'
76
+ };
77
+ }
78
+ const covering = counted.filter((agent) => coversProfile(agent, profileKey));
79
+ const claimants = covering.filter((agent) => publishedOperations(agent).includes('import_table_data'));
80
+ const capable = claimants.filter((agent) => missingOperations(agent).length === 0);
81
+ if (capable.length === 0) {
82
+ const missing = covering.length === 0 ? [...APP_DATABASE_IMPORT_REQUIRED_OPERATIONS] : missingAcross(covering);
83
+ return {
84
+ availableTransforms: [],
85
+ missingOperations: missing,
86
+ profileKey,
87
+ reason: IMPORT_UNAVAILABLE_REASON,
88
+ supportState: 'unsupported'
89
+ };
90
+ }
91
+ const availableTransforms = transformsInCommon(claimants);
92
+ if (!availableTransforms.includes('empty_as_null')) {
93
+ return {
94
+ availableTransforms: [],
95
+ missingOperations: [],
96
+ profileKey,
97
+ reason: IMPORT_EMPTY_AS_NULL_REASON,
98
+ supportState: 'unsupported'
99
+ };
100
+ }
101
+ return {
102
+ availableTransforms,
103
+ missingOperations: [],
104
+ profileKey,
105
+ reason: '',
106
+ supportState: 'supported'
107
+ };
108
+ }
109
+
110
+ function isActiveAgent(agent: AgentLifecycleTagSource): boolean {
111
+ return agent.status === AgentStatus.ACTIVE || agent.status === 'Active';
112
+ }
113
+
114
+ function publishesAgentName(agent: AgentLifecycleTagSource, agentName: string): boolean {
115
+ return (agent.tags?.[DATABASE_LIFECYCLE_TAG_AGENT_NAME] ?? []).includes(agentName);
116
+ }
117
+
118
+ function coversProfile(agent: AgentLifecycleTagSource, profileKey: string): boolean {
119
+ const profiles = agent.tags?.profile ?? [];
120
+ return profiles.includes('*') || profiles.includes(profileKey);
121
+ }
122
+
123
+ function publishedOperations(agent: AgentLifecycleTagSource): readonly string[] {
124
+ return agent.tags?.['databaseLifecycle:operations'] ?? [];
125
+ }
126
+
127
+ function missingOperations(agent: AgentLifecycleTagSource): string[] {
128
+ const operations = publishedOperations(agent);
129
+ return APP_DATABASE_IMPORT_REQUIRED_OPERATIONS.filter((operation) => !operations.includes(operation));
130
+ }
131
+
132
+ function missingAcross(agents: readonly AgentLifecycleTagSource[]): string[] {
133
+ const lists = agents.map(missingOperations).sort((left, right) => left.length - right.length || left.join().localeCompare(right.join()));
134
+ return lists[0] ?? [...APP_DATABASE_IMPORT_REQUIRED_OPERATIONS];
135
+ }
136
+
137
+ function transformsInCommon(agents: readonly AgentLifecycleTagSource[]): AppDatabaseImportTransform[] {
138
+ const counts = new Map<AppDatabaseImportTransform, number>();
139
+ for (const agent of agents) {
140
+ const advertised = new Set(agent.tags?.[DATABASE_LIFECYCLE_TAG_IMPORT_TRANSFORMS] ?? []);
141
+ for (const transform of APP_DATABASE_IMPORT_TRANSFORMS) {
142
+ if (advertised.has(transform)) {
143
+ counts.set(transform, (counts.get(transform) ?? 0) + 1);
144
+ }
145
+ }
146
+ }
147
+ return APP_DATABASE_IMPORT_TRANSFORMS.filter((transform) => counts.get(transform) === agents.length);
148
+ }
@@ -33,8 +33,11 @@ describe('database lifecycle contracts', () => {
33
33
  'complete_import_upload',
34
34
  'prepare_import_prefix_upload',
35
35
  'inspect_import_prefix',
36
+ 'capture_integration_pg_dump',
37
+ 'restore_integration_pg_dump',
36
38
  'restore_import_data',
37
- 'describe_schema'
39
+ 'describe_schema',
40
+ 'import_table_data'
38
41
  ]);
39
42
  });
40
43