@superblocksteam/shared 0.9614.3 → 0.9615.0

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 (173) hide show
  1. package/dist/database-lifecycle/appDatabaseCompatibility.d.ts +5 -3
  2. package/dist/database-lifecycle/appDatabaseCompatibility.d.ts.map +1 -1
  3. package/dist/database-lifecycle/appDatabaseCompatibility.js.map +1 -1
  4. package/dist/database-lifecycle/appDatabaseImport.d.ts +70 -0
  5. package/dist/database-lifecycle/appDatabaseImport.d.ts.map +1 -0
  6. package/dist/database-lifecycle/appDatabaseImport.js +111 -0
  7. package/dist/database-lifecycle/appDatabaseImport.js.map +1 -0
  8. package/dist/database-lifecycle/appDatabaseImport.test.d.ts +2 -0
  9. package/dist/database-lifecycle/appDatabaseImport.test.d.ts.map +1 -0
  10. package/dist/database-lifecycle/appDatabaseImport.test.js +145 -0
  11. package/dist/database-lifecycle/appDatabaseImport.test.js.map +1 -0
  12. package/dist/database-lifecycle/appDatabaseStatus.d.ts +79 -0
  13. package/dist/database-lifecycle/appDatabaseStatus.d.ts.map +1 -0
  14. package/dist/database-lifecycle/appDatabaseStatus.js +28 -0
  15. package/dist/database-lifecycle/appDatabaseStatus.js.map +1 -0
  16. package/dist/database-lifecycle/appDatabaseStatus.test.d.ts +2 -0
  17. package/dist/database-lifecycle/appDatabaseStatus.test.d.ts.map +1 -0
  18. package/dist/database-lifecycle/appDatabaseStatus.test.js +88 -0
  19. package/dist/database-lifecycle/appDatabaseStatus.test.js.map +1 -0
  20. package/dist/database-lifecycle/dataImport.d.ts +53 -0
  21. package/dist/database-lifecycle/dataImport.d.ts.map +1 -0
  22. package/dist/database-lifecycle/dataImport.js +173 -0
  23. package/dist/database-lifecycle/dataImport.js.map +1 -0
  24. package/dist/database-lifecycle/dataImport.test.d.ts +2 -0
  25. package/dist/database-lifecycle/dataImport.test.d.ts.map +1 -0
  26. package/dist/database-lifecycle/dataImport.test.js +84 -0
  27. package/dist/database-lifecycle/dataImport.test.js.map +1 -0
  28. package/dist/database-lifecycle/index.d.ts +101 -5
  29. package/dist/database-lifecycle/index.d.ts.map +1 -1
  30. package/dist/database-lifecycle/index.js +55 -6
  31. package/dist/database-lifecycle/index.js.map +1 -1
  32. package/dist/database-lifecycle/index.test.js +18 -6
  33. package/dist/database-lifecycle/index.test.js.map +1 -1
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/plugins/templates/workflow.d.ts.map +1 -1
  39. package/dist/plugins/templates/workflow.js +1 -3
  40. package/dist/plugins/templates/workflow.js.map +1 -1
  41. package/dist/socket/protocol.d.ts +19 -2
  42. package/dist/socket/protocol.d.ts.map +1 -1
  43. package/dist/socket/protocol.js.map +1 -1
  44. package/dist/types/ai/index.d.ts +39 -0
  45. package/dist/types/ai/index.d.ts.map +1 -1
  46. package/dist/types/ai/index.js +8 -1
  47. package/dist/types/ai/index.js.map +1 -1
  48. package/dist/types/billing/usageSource.d.ts +1 -0
  49. package/dist/types/billing/usageSource.d.ts.map +1 -1
  50. package/dist/types/billing/usageSource.js +19 -1
  51. package/dist/types/billing/usageSource.js.map +1 -1
  52. package/dist/types/event/index.d.ts +6 -1
  53. package/dist/types/event/index.d.ts.map +1 -1
  54. package/dist/types/event/index.js +5 -0
  55. package/dist/types/event/index.js.map +1 -1
  56. package/dist/types/policyGate/index.d.ts +11 -0
  57. package/dist/types/policyGate/index.d.ts.map +1 -1
  58. package/dist/types/policyGate/index.js +9 -0
  59. package/dist/types/policyGate/index.js.map +1 -1
  60. package/dist/utils/attachment-delivery.js +1 -1
  61. package/dist/utils/attachment-delivery.js.map +1 -1
  62. package/dist/utils/attachment-delivery.test.js +11 -0
  63. package/dist/utils/attachment-delivery.test.js.map +1 -1
  64. package/dist/utils/attachment-upload.d.ts +15 -0
  65. package/dist/utils/attachment-upload.d.ts.map +1 -1
  66. package/dist/utils/attachment-upload.js +44 -2
  67. package/dist/utils/attachment-upload.js.map +1 -1
  68. package/dist/utils/attachment-upload.test.js +37 -1
  69. package/dist/utils/attachment-upload.test.js.map +1 -1
  70. package/dist/utils/attachment-validation.d.ts +18 -1
  71. package/dist/utils/attachment-validation.d.ts.map +1 -1
  72. package/dist/utils/attachment-validation.js +38 -5
  73. package/dist/utils/attachment-validation.js.map +1 -1
  74. package/dist/utils/attachment-validation.test.js +22 -3
  75. package/dist/utils/attachment-validation.test.js.map +1 -1
  76. package/dist-esm/database-lifecycle/appDatabaseCompatibility.d.ts +5 -3
  77. package/dist-esm/database-lifecycle/appDatabaseCompatibility.d.ts.map +1 -1
  78. package/dist-esm/database-lifecycle/appDatabaseCompatibility.js.map +1 -1
  79. package/dist-esm/database-lifecycle/appDatabaseImport.d.ts +70 -0
  80. package/dist-esm/database-lifecycle/appDatabaseImport.d.ts.map +1 -0
  81. package/dist-esm/database-lifecycle/appDatabaseImport.js +104 -0
  82. package/dist-esm/database-lifecycle/appDatabaseImport.js.map +1 -0
  83. package/dist-esm/database-lifecycle/appDatabaseImport.test.d.ts +2 -0
  84. package/dist-esm/database-lifecycle/appDatabaseImport.test.d.ts.map +1 -0
  85. package/dist-esm/database-lifecycle/appDatabaseImport.test.js +143 -0
  86. package/dist-esm/database-lifecycle/appDatabaseImport.test.js.map +1 -0
  87. package/dist-esm/database-lifecycle/appDatabaseStatus.d.ts +79 -0
  88. package/dist-esm/database-lifecycle/appDatabaseStatus.d.ts.map +1 -0
  89. package/dist-esm/database-lifecycle/appDatabaseStatus.js +23 -0
  90. package/dist-esm/database-lifecycle/appDatabaseStatus.js.map +1 -0
  91. package/dist-esm/database-lifecycle/appDatabaseStatus.test.d.ts +2 -0
  92. package/dist-esm/database-lifecycle/appDatabaseStatus.test.d.ts.map +1 -0
  93. package/dist-esm/database-lifecycle/appDatabaseStatus.test.js +86 -0
  94. package/dist-esm/database-lifecycle/appDatabaseStatus.test.js.map +1 -0
  95. package/dist-esm/database-lifecycle/dataImport.d.ts +53 -0
  96. package/dist-esm/database-lifecycle/dataImport.d.ts.map +1 -0
  97. package/dist-esm/database-lifecycle/dataImport.js +165 -0
  98. package/dist-esm/database-lifecycle/dataImport.js.map +1 -0
  99. package/dist-esm/database-lifecycle/dataImport.test.d.ts +2 -0
  100. package/dist-esm/database-lifecycle/dataImport.test.d.ts.map +1 -0
  101. package/dist-esm/database-lifecycle/dataImport.test.js +82 -0
  102. package/dist-esm/database-lifecycle/dataImport.test.js.map +1 -0
  103. package/dist-esm/database-lifecycle/index.d.ts +101 -5
  104. package/dist-esm/database-lifecycle/index.d.ts.map +1 -1
  105. package/dist-esm/database-lifecycle/index.js +36 -5
  106. package/dist-esm/database-lifecycle/index.js.map +1 -1
  107. package/dist-esm/database-lifecycle/index.test.js +19 -7
  108. package/dist-esm/database-lifecycle/index.test.js.map +1 -1
  109. package/dist-esm/index.d.ts +1 -0
  110. package/dist-esm/index.d.ts.map +1 -1
  111. package/dist-esm/index.js +1 -0
  112. package/dist-esm/index.js.map +1 -1
  113. package/dist-esm/plugins/templates/workflow.d.ts.map +1 -1
  114. package/dist-esm/plugins/templates/workflow.js +1 -3
  115. package/dist-esm/plugins/templates/workflow.js.map +1 -1
  116. package/dist-esm/socket/protocol.d.ts +19 -2
  117. package/dist-esm/socket/protocol.d.ts.map +1 -1
  118. package/dist-esm/socket/protocol.js.map +1 -1
  119. package/dist-esm/types/ai/index.d.ts +39 -0
  120. package/dist-esm/types/ai/index.d.ts.map +1 -1
  121. package/dist-esm/types/ai/index.js +6 -0
  122. package/dist-esm/types/ai/index.js.map +1 -1
  123. package/dist-esm/types/billing/usageSource.d.ts +1 -0
  124. package/dist-esm/types/billing/usageSource.d.ts.map +1 -1
  125. package/dist-esm/types/billing/usageSource.js +18 -0
  126. package/dist-esm/types/billing/usageSource.js.map +1 -1
  127. package/dist-esm/types/event/index.d.ts +6 -1
  128. package/dist-esm/types/event/index.d.ts.map +1 -1
  129. package/dist-esm/types/event/index.js +5 -0
  130. package/dist-esm/types/event/index.js.map +1 -1
  131. package/dist-esm/types/policyGate/index.d.ts +11 -0
  132. package/dist-esm/types/policyGate/index.d.ts.map +1 -1
  133. package/dist-esm/types/policyGate/index.js +8 -0
  134. package/dist-esm/types/policyGate/index.js.map +1 -1
  135. package/dist-esm/utils/attachment-delivery.js +2 -2
  136. package/dist-esm/utils/attachment-delivery.js.map +1 -1
  137. package/dist-esm/utils/attachment-delivery.test.js +11 -0
  138. package/dist-esm/utils/attachment-delivery.test.js.map +1 -1
  139. package/dist-esm/utils/attachment-upload.d.ts +15 -0
  140. package/dist-esm/utils/attachment-upload.d.ts.map +1 -1
  141. package/dist-esm/utils/attachment-upload.js +41 -1
  142. package/dist-esm/utils/attachment-upload.js.map +1 -1
  143. package/dist-esm/utils/attachment-upload.test.js +38 -2
  144. package/dist-esm/utils/attachment-upload.test.js.map +1 -1
  145. package/dist-esm/utils/attachment-validation.d.ts +18 -1
  146. package/dist-esm/utils/attachment-validation.d.ts.map +1 -1
  147. package/dist-esm/utils/attachment-validation.js +38 -5
  148. package/dist-esm/utils/attachment-validation.js.map +1 -1
  149. package/dist-esm/utils/attachment-validation.test.js +23 -4
  150. package/dist-esm/utils/attachment-validation.test.js.map +1 -1
  151. package/package.json +1 -1
  152. package/src/database-lifecycle/appDatabaseCompatibility.ts +6 -3
  153. package/src/database-lifecycle/appDatabaseImport.test.ts +185 -0
  154. package/src/database-lifecycle/appDatabaseImport.ts +205 -0
  155. package/src/database-lifecycle/appDatabaseStatus.test.ts +105 -0
  156. package/src/database-lifecycle/appDatabaseStatus.ts +95 -0
  157. package/src/database-lifecycle/dataImport.test.ts +95 -0
  158. package/src/database-lifecycle/dataImport.ts +244 -0
  159. package/src/database-lifecycle/index.test.ts +20 -6
  160. package/src/database-lifecycle/index.ts +203 -7
  161. package/src/index.ts +1 -0
  162. package/src/plugins/templates/workflow.ts +1 -3
  163. package/src/socket/protocol.ts +14 -2
  164. package/src/types/ai/index.ts +39 -0
  165. package/src/types/billing/usageSource.ts +20 -0
  166. package/src/types/event/index.ts +6 -1
  167. package/src/types/policyGate/index.ts +14 -0
  168. package/src/utils/attachment-delivery.test.ts +14 -0
  169. package/src/utils/attachment-delivery.ts +2 -2
  170. package/src/utils/attachment-upload.test.ts +48 -1
  171. package/src/utils/attachment-upload.ts +48 -1
  172. package/src/utils/attachment-validation.test.ts +35 -3
  173. package/src/utils/attachment-validation.ts +52 -6
@@ -0,0 +1,185 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import {
4
+ isAppDatabaseImportCallbackResult,
5
+ isAppDatabaseImportDispatchPayload,
6
+ parseAppDatabaseImportValidationMetadata
7
+ } from './appDatabaseImport.js';
8
+ import type { AppDatabaseImportCallback } from './index.js';
9
+
10
+ describe('App Database import contracts', () => {
11
+ test('validates operation-specific dispatch fields', () => {
12
+ const base = {
13
+ applicationId: 'application-id',
14
+ bindingId: 'binding-id',
15
+ bindingKey: 'binding-key',
16
+ desiredSpec: { engine: 'postgres', logicalName: 'Orders DB' },
17
+ desiredSpecHash: 'desired-spec-hash',
18
+ import: {
19
+ importId: 'import-id',
20
+ kind: 'pg_dump_custom',
21
+ prefixSizeBytes: 1_024,
22
+ profileToken: 'profile-token',
23
+ resourceToken: 'resource-token'
24
+ },
25
+ organizationId: 'organization-id',
26
+ profile: 'production',
27
+ requestId: 'request-id',
28
+ resourceKey: 'organization/application/database/profile/default'
29
+ };
30
+
31
+ expect(
32
+ isAppDatabaseImportDispatchPayload({
33
+ ...base,
34
+ import: { ...base.import, expectedSizeBytes: 10_000 },
35
+ operation: 'prepare_import_prefix_upload'
36
+ })
37
+ ).toBe(true);
38
+ expect(
39
+ isAppDatabaseImportDispatchPayload({
40
+ ...base,
41
+ import: base.import,
42
+ operation: 'prepare_import_prefix_upload'
43
+ })
44
+ ).toBe(false);
45
+ expect(
46
+ isAppDatabaseImportDispatchPayload({
47
+ ...base,
48
+ import: {
49
+ ...base.import,
50
+ expectedSizeBytes: 10_000,
51
+ prefixObjectKey: 'imports/import-id/prefix'
52
+ },
53
+ operation: 'prepare_import_prefix_upload'
54
+ })
55
+ ).toBe(false);
56
+ expect(
57
+ isAppDatabaseImportDispatchPayload({
58
+ ...base,
59
+ import: { ...base.import, prefixObjectKey: 'imports/import-id/prefix' },
60
+ operation: 'inspect_import_prefix'
61
+ })
62
+ ).toBe(true);
63
+ expect(
64
+ isAppDatabaseImportDispatchPayload({
65
+ ...base,
66
+ import: {
67
+ ...base.import,
68
+ expectedSizeBytes: 10_000,
69
+ prefixObjectKey: 'imports/import-id/prefix'
70
+ },
71
+ operation: 'inspect_import_prefix'
72
+ })
73
+ ).toBe(false);
74
+ expect({
75
+ ...base,
76
+ import: { ...base.import, expectedSizeBytes: 10_000 },
77
+ operation: 'prepare_import_prefix_upload'
78
+ }).not.toHaveProperty('import.prefixObjectKey');
79
+ expect(
80
+ isAppDatabaseImportDispatchPayload({
81
+ ...base,
82
+ operation: 'inspect_import_prefix'
83
+ })
84
+ ).toBe(false);
85
+ expect(
86
+ isAppDatabaseImportDispatchPayload({
87
+ ...base,
88
+ import: { ...base.import, profileToken: '' },
89
+ operation: 'prepare_import_prefix_upload'
90
+ })
91
+ ).toBe(false);
92
+ expect(
93
+ isAppDatabaseImportDispatchPayload({
94
+ ...base,
95
+ import: { ...base.import, resourceToken: '' },
96
+ operation: 'prepare_import_prefix_upload'
97
+ })
98
+ ).toBe(false);
99
+ const withoutOrganization: Record<string, unknown> = { ...base };
100
+ delete withoutOrganization.organizationId;
101
+ expect(
102
+ isAppDatabaseImportDispatchPayload({
103
+ ...withoutOrganization,
104
+ import: { ...base.import, expectedSizeBytes: 10_000 },
105
+ operation: 'prepare_import_prefix_upload'
106
+ })
107
+ ).toBe(false);
108
+ });
109
+
110
+ test('requires prefix growth and narrow nested callback results', () => {
111
+ expect(
112
+ isAppDatabaseImportCallbackResult({
113
+ inspectedPrefixSizeBytes: 1_024,
114
+ nextPrefixSizeBytes: 2_048,
115
+ result: 'prefix_incomplete'
116
+ })
117
+ ).toBe(true);
118
+ expect(
119
+ isAppDatabaseImportCallbackResult({
120
+ inspectedPrefixSizeBytes: 1_024,
121
+ nextPrefixSizeBytes: 1_024,
122
+ result: 'prefix_incomplete'
123
+ })
124
+ ).toBe(false);
125
+ expect(
126
+ isAppDatabaseImportCallbackResult({
127
+ prefixObjectKey: 'imports/import-id/prefix',
128
+ prefixUploadUrl: 'https://uploads.example/prefix',
129
+ result: 'prefix_upload_url'
130
+ })
131
+ ).toBe(true);
132
+ expect(
133
+ isAppDatabaseImportCallbackResult({
134
+ prefixUploadUrl: 'https://uploads.example/prefix',
135
+ result: 'prefix_upload_url'
136
+ })
137
+ ).toBe(false);
138
+
139
+ const callback: AppDatabaseImportCallback = {
140
+ bindingKey: 'binding-key',
141
+ import: {
142
+ prefixObjectKey: 'imports/import-id/prefix',
143
+ prefixUploadUrl: 'https://uploads.example/prefix',
144
+ result: 'prefix_upload_url'
145
+ },
146
+ lifecycleState: 'ready',
147
+ requestId: 'request-id'
148
+ };
149
+ if (callback.import.result !== 'prefix_upload_url') {
150
+ throw new Error('Expected prefix upload URL callback');
151
+ }
152
+ expect(callback.import.prefixUploadUrl).toContain('https://');
153
+ expect(
154
+ isAppDatabaseImportCallbackResult({
155
+ result: 'schema_validated',
156
+ validationMetadata: {
157
+ postgresMajorVersion: 16,
158
+ tableNames: ['public.orders']
159
+ }
160
+ })
161
+ ).toBe(true);
162
+ expect(
163
+ isAppDatabaseImportCallbackResult({
164
+ postgresMajorVersion: 16,
165
+ result: 'schema_validated',
166
+ tableNames: ['public.orders']
167
+ })
168
+ ).toBe(false);
169
+ });
170
+
171
+ test('retains only allowlisted validation metadata', () => {
172
+ expect(
173
+ parseAppDatabaseImportValidationMetadata({
174
+ postgresMajorVersion: 16,
175
+ rows: [{ secret: 'value' }],
176
+ sql: 'CREATE TABLE private_data',
177
+ tableNames: ['public.orders', 'public.customers']
178
+ })
179
+ ).toEqual({
180
+ postgresMajorVersion: 16,
181
+ tableNames: ['public.orders', 'public.customers']
182
+ });
183
+ expect(parseAppDatabaseImportValidationMetadata({ postgresMajorVersion: 16, tableNames: [''] })).toBeUndefined();
184
+ });
185
+ });
@@ -0,0 +1,205 @@
1
+ import type { DataImportKind, DataImportState } from './dataImport.js';
2
+
3
+ export const APP_DATABASE_IMPORT_OPERATIONS = ['prepare_import_prefix_upload', 'inspect_import_prefix'] as const;
4
+ // Subset of DATA_IMPORT_STATES. Prefix success is schema_validated, not ready.
5
+ export const APP_DATABASE_IMPORT_STATES = [
6
+ 'requested',
7
+ 'preparing_prefix_upload',
8
+ 'awaiting_prefix_upload',
9
+ 'validating_schema',
10
+ 'schema_validated',
11
+ 'failed',
12
+ 'cancelled'
13
+ ] as const satisfies readonly DataImportState[];
14
+ export const APP_DATABASE_IMPORT_ERROR_CODES = [
15
+ 'prefix_incomplete',
16
+ 'schema_mismatch',
17
+ 'unsupported_archive_format',
18
+ 'archive_corrupt',
19
+ 'postgres_version_newer_than_target',
20
+ 'unsupported_object',
21
+ 'schema_not_aligned',
22
+ 'missing_bucket_access',
23
+ 'internal',
24
+ 'invalid_input',
25
+ 'size_mismatch'
26
+ ] as const;
27
+
28
+ export type AppDatabaseImportKind = Extract<DataImportKind, 'pg_dump_custom'>;
29
+ export type AppDatabaseImportLifecycleOperation = (typeof APP_DATABASE_IMPORT_OPERATIONS)[number];
30
+ export type AppDatabaseImportState = (typeof APP_DATABASE_IMPORT_STATES)[number];
31
+ export type AppDatabaseImportErrorCode = (typeof APP_DATABASE_IMPORT_ERROR_CODES)[number];
32
+
33
+ export type AppDatabaseImportValidationMetadata = {
34
+ postgresMajorVersion: number;
35
+ tableNames: string[];
36
+ };
37
+
38
+ export type AppDatabaseImportDispatchDetails = {
39
+ importId: string;
40
+ kind: AppDatabaseImportKind;
41
+ prefixSizeBytes: number;
42
+ profileToken: string;
43
+ resourceToken: string;
44
+ };
45
+
46
+ type AppDatabaseImportDispatchBase<TDesiredSpec> = {
47
+ applicationId: string;
48
+ bindingId: string;
49
+ bindingKey: string;
50
+ connectionMetadata?: Record<string, string | number | boolean>;
51
+ continuation?: never;
52
+ desiredSpec: TDesiredSpec;
53
+ desiredSpecHash: string;
54
+ environment?: never;
55
+ metadata?: never;
56
+ migrations?: never;
57
+ organizationId: string;
58
+ profile: string;
59
+ provisioningId?: never;
60
+ requestId: string;
61
+ resourceKey: string;
62
+ };
63
+
64
+ export type PrepareImportPrefixUploadDispatchPayloadEnvelope<TDesiredSpec> = AppDatabaseImportDispatchBase<TDesiredSpec> & {
65
+ import: AppDatabaseImportDispatchDetails & {
66
+ expectedSizeBytes: number;
67
+ };
68
+ operation: 'prepare_import_prefix_upload';
69
+ };
70
+
71
+ export type InspectImportPrefixDispatchPayloadEnvelope<TDesiredSpec> = AppDatabaseImportDispatchBase<TDesiredSpec> & {
72
+ import: AppDatabaseImportDispatchDetails & {
73
+ prefixObjectKey: string;
74
+ };
75
+ operation: 'inspect_import_prefix';
76
+ };
77
+
78
+ export type AppDatabaseImportDispatchPayloadEnvelope<TDesiredSpec> =
79
+ | InspectImportPrefixDispatchPayloadEnvelope<TDesiredSpec>
80
+ | PrepareImportPrefixUploadDispatchPayloadEnvelope<TDesiredSpec>;
81
+
82
+ export type PrepareImportPrefixUploadCallbackResult = {
83
+ prefixObjectKey: string;
84
+ prefixUploadHeaders?: Record<string, string>;
85
+ prefixUploadUrl: string;
86
+ result: 'prefix_upload_url';
87
+ };
88
+
89
+ export type InspectImportPrefixCallbackResult =
90
+ | {
91
+ inspectedPrefixSizeBytes: number;
92
+ nextPrefixSizeBytes: number;
93
+ result: 'prefix_incomplete';
94
+ }
95
+ | {
96
+ result: 'schema_validated';
97
+ validationMetadata: AppDatabaseImportValidationMetadata;
98
+ };
99
+
100
+ export type AppDatabaseImportCallbackResult = InspectImportPrefixCallbackResult | PrepareImportPrefixUploadCallbackResult;
101
+
102
+ const importOperations = new Set<string>(APP_DATABASE_IMPORT_OPERATIONS);
103
+
104
+ export function isAppDatabaseImportLifecycleOperation(value: string): value is AppDatabaseImportLifecycleOperation {
105
+ return importOperations.has(value);
106
+ }
107
+
108
+ export function isAppDatabaseImportDispatchPayload(
109
+ value: unknown
110
+ ): value is AppDatabaseImportDispatchPayloadEnvelope<Record<string, unknown>> {
111
+ if (!isUnknownRecord(value) || !hasImportDispatchBase(value)) {
112
+ return false;
113
+ }
114
+ if (!isNonEmptyString(value.import.profileToken) || !isNonEmptyString(value.import.resourceToken)) {
115
+ return false;
116
+ }
117
+ if (value.operation === 'prepare_import_prefix_upload') {
118
+ return value.import.prefixObjectKey === undefined && isPositiveInteger(value.import.expectedSizeBytes);
119
+ }
120
+ return (
121
+ value.operation === 'inspect_import_prefix' &&
122
+ value.import.expectedSizeBytes === undefined &&
123
+ isNonEmptyString(value.import.prefixObjectKey)
124
+ );
125
+ }
126
+
127
+ export function isAppDatabaseImportCallbackResult(value: unknown): value is AppDatabaseImportCallbackResult {
128
+ if (!isUnknownRecord(value)) {
129
+ return false;
130
+ }
131
+ if (value.result === 'prefix_upload_url') {
132
+ return (
133
+ isNonEmptyString(value.prefixObjectKey) && isNonEmptyString(value.prefixUploadUrl) && isOptionalStringMap(value.prefixUploadHeaders)
134
+ );
135
+ }
136
+ if (value.result === 'prefix_incomplete') {
137
+ return (
138
+ isPositiveInteger(value.inspectedPrefixSizeBytes) &&
139
+ isPositiveInteger(value.nextPrefixSizeBytes) &&
140
+ value.nextPrefixSizeBytes > value.inspectedPrefixSizeBytes
141
+ );
142
+ }
143
+ return value.result === 'schema_validated' && parseAppDatabaseImportValidationMetadata(value.validationMetadata) !== undefined;
144
+ }
145
+
146
+ // Rebuild metadata from its allowlisted fields so archive contents, SQL, rows,
147
+ // and other worker-provided details cannot cross this contract boundary.
148
+ export function parseAppDatabaseImportValidationMetadata(value: unknown): AppDatabaseImportValidationMetadata | undefined {
149
+ if (
150
+ !isUnknownRecord(value) ||
151
+ !isPositiveInteger(value.postgresMajorVersion) ||
152
+ !Array.isArray(value.tableNames) ||
153
+ !value.tableNames.every(isNonEmptyString)
154
+ ) {
155
+ return undefined;
156
+ }
157
+ return {
158
+ postgresMajorVersion: value.postgresMajorVersion,
159
+ tableNames: [...value.tableNames]
160
+ };
161
+ }
162
+
163
+ type ImportDispatchRecord = Record<string, unknown> & {
164
+ import: Record<string, unknown>;
165
+ };
166
+
167
+ function hasImportDispatchBase(value: Record<string, unknown>): value is ImportDispatchRecord {
168
+ return (
169
+ isNonEmptyString(value.applicationId) &&
170
+ isNonEmptyString(value.bindingId) &&
171
+ isNonEmptyString(value.bindingKey) &&
172
+ isUnknownRecord(value.desiredSpec) &&
173
+ isNonEmptyString(value.desiredSpecHash) &&
174
+ isUnknownRecord(value.import) &&
175
+ isNonEmptyString(value.import.importId) &&
176
+ value.import.kind === 'pg_dump_custom' &&
177
+ isPositiveInteger(value.import.prefixSizeBytes) &&
178
+ isNonEmptyString(value.organizationId) &&
179
+ isNonEmptyString(value.profile) &&
180
+ isNonEmptyString(value.requestId) &&
181
+ isNonEmptyString(value.resourceKey)
182
+ );
183
+ }
184
+
185
+ function isOptionalStringMap(value: unknown): value is Record<string, string> | undefined {
186
+ if (value === undefined) {
187
+ return true;
188
+ }
189
+ if (!isUnknownRecord(value)) {
190
+ return false;
191
+ }
192
+ return Object.entries(value).every(([key, entry]) => key.length > 0 && typeof entry === 'string' && entry.length > 0);
193
+ }
194
+
195
+ function isNonEmptyString(value: unknown): value is string {
196
+ return typeof value === 'string' && value.trim().length > 0;
197
+ }
198
+
199
+ function isPositiveInteger(value: unknown): value is number {
200
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
201
+ }
202
+
203
+ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
204
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
205
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { ViewMode } from '../types/event/index.js';
4
+ import { isProductionDatabaseRetirementEligible, type AppDatabaseStatus, type AppDatabaseStatusBinding } from './appDatabaseStatus.js';
5
+
6
+ const binding: AppDatabaseStatusBinding = {
7
+ bindingId: 'binding-1',
8
+ created: '2026-09-14T00:00:00.000Z',
9
+ instance: null,
10
+ integration: null,
11
+ lastActivity: '2026-09-14T00:00:00.000Z',
12
+ lifecycleState: 'ready',
13
+ logicalName: 'app_db',
14
+ migrationDirectory: null,
15
+ migrationState: 'migrated'
16
+ };
17
+
18
+ function status(overrides: Partial<AppDatabaseStatus> = {}): AppDatabaseStatus {
19
+ return {
20
+ applicationId: 'app-1',
21
+ hasAppDatabase: false,
22
+ hasDatabaseCode: false,
23
+ liveVersionHasAppDatabase: false,
24
+ tags: [
25
+ {
26
+ availableModes: [ViewMode.DEPLOYED],
27
+ binding,
28
+ deleted: false,
29
+ displayName: 'Production',
30
+ isDefaultEditor: false,
31
+ profileKey: 'production'
32
+ }
33
+ ],
34
+ ...overrides
35
+ };
36
+ }
37
+
38
+ describe('production database retirement eligibility', () => {
39
+ it('offers deletion after a live version that does not need a database and Edit leftovers are gone', () => {
40
+ expect(isProductionDatabaseRetirementEligible(status())).toBe(true);
41
+ });
42
+
43
+ it('refuses deletion while the live version still needs a database', () => {
44
+ expect(isProductionDatabaseRetirementEligible(status({ liveVersionHasAppDatabase: true }))).toBe(false);
45
+ });
46
+
47
+ it('refuses deletion while an Edit-only database remains', () => {
48
+ expect(
49
+ isProductionDatabaseRetirementEligible(
50
+ status({
51
+ tags: [
52
+ ...status().tags,
53
+ {
54
+ availableModes: [ViewMode.EDITOR],
55
+ binding: { ...binding, bindingId: 'edit-binding' },
56
+ deleted: false,
57
+ displayName: 'Staging',
58
+ isDefaultEditor: true,
59
+ profileKey: 'staging'
60
+ }
61
+ ]
62
+ })
63
+ )
64
+ ).toBe(false);
65
+ });
66
+
67
+ it('offers deletion of an unassigned leftover after a live version that does not need a database', () => {
68
+ expect(
69
+ isProductionDatabaseRetirementEligible(
70
+ status({
71
+ tags: [
72
+ {
73
+ availableModes: [],
74
+ binding,
75
+ deleted: false,
76
+ displayName: 'Removed production',
77
+ isDefaultEditor: false,
78
+ profileKey: 'removed-production'
79
+ }
80
+ ]
81
+ })
82
+ )
83
+ ).toBe(true);
84
+ });
85
+
86
+ it('refuses deletion while the editor default is still live, even when that tag is also deploy-allowed', () => {
87
+ expect(
88
+ isProductionDatabaseRetirementEligible(
89
+ status({
90
+ tags: [
91
+ ...status().tags,
92
+ {
93
+ availableModes: [ViewMode.EDITOR, ViewMode.DEPLOYED],
94
+ binding: { ...binding, bindingId: 'edit-binding' },
95
+ deleted: false,
96
+ displayName: 'Staging',
97
+ isDefaultEditor: true,
98
+ profileKey: 'staging'
99
+ }
100
+ ]
101
+ })
102
+ )
103
+ ).toBe(false);
104
+ });
105
+ });
@@ -0,0 +1,95 @@
1
+ import { ViewMode } from '../types/event/index.js';
2
+ import type { AppDatabaseTagRef } from './appDatabaseCompatibility.js';
3
+ import type { DatabaseEngine, LifecycleMigrationState, LifecycleState } from './index.js';
4
+
5
+ /**
6
+ * Builder-safe physical instance fields. Pool capacity and the internal
7
+ * instance id stay off this payload: they are org-wide admin facts, and
8
+ * capacity.used counts every application in the org.
9
+ */
10
+ export type AppDatabaseStatusInstance = {
11
+ engine: DatabaseEngine;
12
+ engineVersion: string | null;
13
+ region: string;
14
+ };
15
+
16
+ export type AppDatabaseStatusIntegration = {
17
+ apiNames: string[];
18
+ id: string;
19
+ name: string;
20
+ pluginId: string;
21
+ };
22
+
23
+ export type AppDatabaseStatusBinding = {
24
+ bindingId: string;
25
+ created: string;
26
+ instance: AppDatabaseStatusInstance | null;
27
+ integration: AppDatabaseStatusIntegration | null;
28
+ lastActivity: string;
29
+ lifecycleState: LifecycleState;
30
+ logicalName: string;
31
+ migrationDirectory: string | null;
32
+ migrationState: LifecycleMigrationState;
33
+ };
34
+
35
+ export type AppDatabaseStatusTag = AppDatabaseTagRef & {
36
+ /** Null when this data tag has no live (non-cancelled) database. */
37
+ binding: AppDatabaseStatusBinding | null;
38
+ /**
39
+ * True when this data tag had a database that was retired. The live
40
+ * binding is omitted so the tag can be provisioned again, but Status
41
+ * still shows Deleted instead of No database.
42
+ */
43
+ deleted: boolean;
44
+ displayName: string;
45
+ isDefaultEditor: boolean;
46
+ };
47
+
48
+ export type AppDatabaseStatus = {
49
+ applicationId: string;
50
+ /**
51
+ * Whether the app declares an App Database right now (working-state
52
+ * requirement). Same meaning as AppDatabaseCompatibility.hasAppDatabase.
53
+ * A leftover live binding with no requirement still appears under tags.
54
+ */
55
+ hasAppDatabase: boolean;
56
+ /**
57
+ * Whether leftover App Database code is still in the working tree: SQL
58
+ * in the last known migration folder, or APIs still wired to the app's
59
+ * Postgres integration. Used to offer Clark cleanup after Edit databases
60
+ * are gone. Not a complete scan of every page or helper.
61
+ */
62
+ hasDatabaseCode: boolean;
63
+ /**
64
+ * Whether the currently live published version stamped a database
65
+ * requirement. Working-state (Edit) rows are not this signal: a leftover
66
+ * production database stays undeletable until a version without databases
67
+ * is live.
68
+ */
69
+ liveVersionHasAppDatabase: boolean;
70
+ tags: AppDatabaseStatusTag[];
71
+ };
72
+
73
+ /**
74
+ * Edit lifecycle membership: tags used for edit provisioning and migrations.
75
+ * That is every editor-only tag, plus the editor default even when that
76
+ * default is also deploy-allowed.
77
+ */
78
+ export function isEditLifecycleTag(tag: Pick<AppDatabaseStatusTag, 'availableModes'>): boolean {
79
+ return tag.availableModes.includes(ViewMode.EDITOR);
80
+ }
81
+
82
+ /**
83
+ * Deploy lifecycle membership for builder-facing grouping: deploy-allowed
84
+ * tags that are not the editor default. Publish still may write the editor
85
+ * default when it is also deploy-allowed; that tag stays an Edit database.
86
+ */
87
+ export function isDeployLifecycleTag(tag: Pick<AppDatabaseStatusTag, 'availableModes'>): boolean {
88
+ return tag.availableModes.includes(ViewMode.DEPLOYED) && !tag.availableModes.includes(ViewMode.EDITOR);
89
+ }
90
+
91
+ export function isProductionDatabaseRetirementEligible(status: AppDatabaseStatus): boolean {
92
+ const hasEditDatabase = status.tags.some((tag) => tag.binding !== null && isEditLifecycleTag(tag));
93
+ const hasLeftoverProductionDatabase = status.tags.some((tag) => tag.binding !== null && !isEditLifecycleTag(tag));
94
+ return !hasEditDatabase && hasLeftoverProductionDatabase && !status.liveVersionHasAppDatabase;
95
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import {
4
+ mintDataImportConcurrencyKey,
5
+ parseDataImportMetadata,
6
+ parseDataImportTarget,
7
+ type DataImportUploadMetadata
8
+ } from './dataImport.js';
9
+
10
+ describe('data import contracts', () => {
11
+ test('validates destination descriptors without fixing the record to one destination schema', () => {
12
+ expect(
13
+ parseDataImportTarget({
14
+ applicationId: 'application-id',
15
+ bindingId: 'binding-id',
16
+ profile: 'production',
17
+ type: 'app_database'
18
+ })
19
+ ).toEqual({
20
+ applicationId: 'application-id',
21
+ bindingId: 'binding-id',
22
+ profile: 'production',
23
+ type: 'app_database'
24
+ });
25
+ expect(parseDataImportTarget({ applicationId: 'application-id', type: 'app_database' })).toBeUndefined();
26
+ expect(parseDataImportTarget({ resourceId: 'resource-id', type: 'unknown' })).toBeUndefined();
27
+ });
28
+
29
+ test('keeps signed URLs and part ETags out of durable upload metadata', () => {
30
+ const metadata: DataImportUploadMetadata = {
31
+ completedPartNumbers: [1, 2],
32
+ filename: 'data.csv',
33
+ objectKey: 'imports/profile/resource/import/data.csv',
34
+ partCount: 2,
35
+ partSizeBytes: 16 * 1024 * 1024,
36
+ uploadId: 'upload-id'
37
+ };
38
+ expect(metadata.completedPartNumbers).toEqual([1, 2]);
39
+
40
+ // @ts-expect-error Signed URLs are callback-only.
41
+ const withSignedUrl: DataImportUploadMetadata = { filename: 'data.csv', uploadUrl: 'https://uploads.example/object' };
42
+ // @ts-expect-error Part ETags are sent for completion, not persisted.
43
+ const withEtags: DataImportUploadMetadata = { eTags: ['etag'], filename: 'data.csv' };
44
+ expect(withSignedUrl).toBeDefined();
45
+ expect(withEtags).toBeDefined();
46
+ });
47
+
48
+ test('stores one csv table spelling so equivalent names share an active key', () => {
49
+ const target = {
50
+ applicationId: 'application-id',
51
+ bindingId: 'binding-id',
52
+ profile: 'production',
53
+ type: 'app_database' as const
54
+ };
55
+ const spaced = parseDataImportMetadata('csv', { kind: 'csv', mode: 'append', table: ' public.orders ' });
56
+ const plain = parseDataImportMetadata('csv', { kind: 'csv', mode: 'append', table: 'public.orders' });
57
+
58
+ expect(spaced).toEqual({ kind: 'csv', mode: 'append', table: 'public.orders' });
59
+ expect(plain).toEqual(spaced);
60
+ if (!spaced || !plain) {
61
+ throw new Error('expected both table spellings to parse');
62
+ }
63
+ expect(mintDataImportConcurrencyKey(spaced, target)).toBe(`csv:${target.bindingId}:public.orders`);
64
+ expect(mintDataImportConcurrencyKey(plain, target)).toBe(mintDataImportConcurrencyKey(spaced, target));
65
+ expect(parseDataImportMetadata('csv', { kind: 'csv', mode: 'append', table: ' ' })).toBeUndefined();
66
+ expect(parseDataImportMetadata('csv', { kind: 'csv', mode: 'append', table: 'public. order items' })).toBeUndefined();
67
+ for (const table of ['PUBLIC.ORDERS', 'public."orders"', '"public"."orders"', 'orders']) {
68
+ expect(parseDataImportMetadata('csv', { kind: 'csv', mode: 'append', table })).toBeUndefined();
69
+ }
70
+ const sixtyThree = 'a'.repeat(63);
71
+ const sixtyFour = 'a'.repeat(64);
72
+ expect(parseDataImportMetadata('csv', { kind: 'csv', mode: 'append', table: `public.${sixtyThree}` })).toEqual({
73
+ kind: 'csv',
74
+ mode: 'append',
75
+ table: `public.${sixtyThree}`
76
+ });
77
+ expect(parseDataImportMetadata('csv', { kind: 'csv', mode: 'append', table: `public.${sixtyFour}` })).toBeUndefined();
78
+ expect(parseDataImportMetadata('csv', { kind: 'csv', mode: 'append', table: `${sixtyFour}.orders` })).toBeUndefined();
79
+ expect(parseDataImportMetadata('csv', { kind: 'csv', mode: 'append', table: `public.${sixtyThree}x` })).toBeUndefined();
80
+ expect(parseDataImportMetadata('csv', { kind: 'csv', mode: 'append', table: `public.${sixtyThree}y` })).toBeUndefined();
81
+ });
82
+
83
+ test('rejects zero prefix sizes and a zero postgres major', () => {
84
+ const base = { kind: 'pg_dump_custom' as const };
85
+ expect(parseDataImportMetadata('pg_dump_custom', { ...base, currentPrefixSizeBytes: 0 })).toBeUndefined();
86
+ expect(parseDataImportMetadata('pg_dump_custom', { ...base, requestedPrefixSizeBytes: 0 })).toBeUndefined();
87
+ expect(parseDataImportMetadata('pg_dump_custom', { ...base, postgresMajorVersion: 0 })).toBeUndefined();
88
+ expect(parseDataImportMetadata('pg_dump_custom', { ...base, postgresMajorVersion: -1 })).toBeUndefined();
89
+ expect(parseDataImportMetadata('pg_dump_custom', { ...base, currentPrefixSizeBytes: 1, postgresMajorVersion: 16 })).toEqual({
90
+ kind: 'pg_dump_custom',
91
+ currentPrefixSizeBytes: 1,
92
+ postgresMajorVersion: 16
93
+ });
94
+ });
95
+ });