@metaobjectsdev/migrate-ts 0.6.0 → 0.7.0-rc.2

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.
@@ -1,10 +1,11 @@
1
- import type { MetaData, MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
1
+ import type { ColumnNamingStrategy, MetaData, MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
2
2
  import {
3
3
  TYPE_OBJECT,
4
4
  MetaSource,
5
5
  IDENTITY_ATTR_GENERATION,
6
6
  IDENTITY_ATTR_UNIQUE,
7
7
  FIELD_ATTR_DEFAULT,
8
+ FIELD_ATTR_MAX_LENGTH,
8
9
  FIELD_ATTR_UNIQUE,
9
10
  FIELD_SUBTYPE_STRING,
10
11
  FIELD_SUBTYPE_INT,
@@ -25,12 +26,14 @@ import {
25
26
  FIELD_ATTR_STORAGE,
26
27
  STORAGE_FLATTENED,
27
28
  DOC_ATTR_DESCRIPTION,
29
+ applyColumnNamingStrategy, DEFAULT_COLUMN_NAMING_STRATEGY,
28
30
  resolveTableName, resolveColumnName, resolveTableSchema,
29
31
  } from "@metaobjectsdev/metadata";
30
32
  import type { SqlType } from "./sql-type.js";
31
33
  import type {
32
34
  Dialect, SchemaSnapshot, TableDescriptor, ColumnDescriptor, IndexDescriptor, FkDescriptor,
33
35
  } from "./types.js";
36
+ import { buildExpectedViews } from "./expected-views.js";
34
37
  import {
35
38
  resolveReferentialActions,
36
39
  validateSetNullNullability,
@@ -49,6 +52,12 @@ export interface BuildExpectedSchemaOptions {
49
52
  * patterns produce INTEGER / TEXT in the actual DB.
50
53
  */
51
54
  dialect?: Dialect;
55
+ /**
56
+ * Column-naming strategy for fields with no `@column` override. Defaults to
57
+ * `"snake_case"`. Must match the runtime's `ObjectManager` strategy — a
58
+ * mismatch yields a schema whose columns the runtime can't address.
59
+ */
60
+ columnNamingStrategy?: ColumnNamingStrategy;
52
61
  }
53
62
 
54
63
  export function buildExpectedSchema(
@@ -58,6 +67,7 @@ export function buildExpectedSchema(
58
67
  // D1 is SQLite at the SQL level; normalize it so downstream dialect checks
59
68
  // don't need to handle "d1" separately.
60
69
  const dialect = opts?.dialect === "d1" ? "sqlite" : opts?.dialect;
70
+ const strategy: ColumnNamingStrategy = opts?.columnNamingStrategy ?? DEFAULT_COLUMN_NAMING_STRATEGY;
61
71
 
62
72
  // Pass 1: collect entities + their resolved table names.
63
73
  // Skip:
@@ -87,7 +97,7 @@ export function buildExpectedSchema(
87
97
  // Schema is resolved here (not stored in Pass 1) to avoid exactOptionalPropertyTypes
88
98
  // issues with `string | undefined` vs `schema?: string`.
89
99
  const tables: TableDescriptor[] = entities.map(({ entity, tableName }) => {
90
- const t = buildTable(entity, tableName, resolveTargetTable, root as MetaRoot);
100
+ const t = buildTable(entity, tableName, resolveTargetTable, root as MetaRoot, strategy);
91
101
  const schema = resolveTableSchema(entity);
92
102
  if (schema !== undefined) t.schema = schema;
93
103
  return t;
@@ -113,7 +123,12 @@ export function buildExpectedSchema(
113
123
  }
114
124
  }
115
125
 
116
- return { tables, views: [] };
126
+ // Pass 4: views from read-only projections. Built regardless of dialect so
127
+ // the diff produces correct create-view changes; emit() refuses them for
128
+ // sqlite/d1 with a clear error ("view migration not implemented for ...").
129
+ const views = buildExpectedViews(root as MetaRoot, strategy);
130
+
131
+ return { tables, views };
117
132
  }
118
133
 
119
134
  /**
@@ -143,6 +158,7 @@ function buildTable(
143
158
  tableName: string,
144
159
  resolveTargetTable: (entityName: string) => string | undefined,
145
160
  root: MetaRoot,
161
+ strategy: ColumnNamingStrategy,
146
162
  ): TableDescriptor {
147
163
  // Use effective accessors so inherited fields/identities (from `extends:` /
148
164
  // abstract bases like BaseEntity) are included.
@@ -155,7 +171,7 @@ function buildTable(
155
171
 
156
172
  const primaryKey = pkJsNames.map((jsName) => {
157
173
  const field = findField(entity, jsName);
158
- return field ? resolveColumnName(field) : toSnake(jsName);
174
+ return field ? resolveColumnName(field, strategy) : applyColumnNamingStrategy(jsName, strategy);
159
175
  });
160
176
 
161
177
  const columns: ColumnDescriptor[] = [];
@@ -167,17 +183,17 @@ function buildTable(
167
183
  ) {
168
184
  // Flattened storage: expand nested value-object fields as prefixed columns.
169
185
  // The parent field.object itself does NOT produce its own column.
170
- columns.push(...flattenObjectField(field, root));
186
+ columns.push(...flattenObjectField(field, root, strategy));
171
187
  } else {
172
- columns.push(buildColumn(field, isPk, isPk ? pkGeneration : undefined));
188
+ columns.push(buildColumn(field, isPk, isPk ? pkGeneration : undefined, strategy));
173
189
  }
174
190
  }
175
191
 
176
192
  const descriptor: TableDescriptor = {
177
193
  name: tableName,
178
194
  columns,
179
- indexes: buildSecondaryIndexes(entity, tableName),
180
- foreignKeys: buildForeignKeys(entity, tableName, resolveTargetTable, root),
195
+ indexes: buildSecondaryIndexes(entity, tableName, strategy),
196
+ foreignKeys: buildForeignKeys(entity, tableName, resolveTargetTable, root, strategy),
181
197
  primaryKey,
182
198
  };
183
199
  const entityDesc = readDescription(entity);
@@ -196,7 +212,9 @@ function readDescription(node: { attr: (n: string) => unknown }): string | undef
196
212
  return typeof v === "string" && v.length > 0 ? v : undefined;
197
213
  }
198
214
 
199
- function buildSecondaryIndexes(entity: MetaObject, tableName: string): IndexDescriptor[] {
215
+ function buildSecondaryIndexes(
216
+ entity: MetaObject, tableName: string, strategy: ColumnNamingStrategy,
217
+ ): IndexDescriptor[] {
200
218
  const indexes: IndexDescriptor[] = [];
201
219
 
202
220
  // (a) Implicit unique indexes from @unique fields. Drizzle auto-creates these
@@ -205,7 +223,7 @@ function buildSecondaryIndexes(entity: MetaObject, tableName: string): IndexDesc
205
223
  // doesn't see them as drop-only on the actual side.
206
224
  for (const field of entity.fields()) {
207
225
  if (field.ownAttr(FIELD_ATTR_UNIQUE) !== true) continue;
208
- const colName = resolveColumnName(field);
226
+ const colName = resolveColumnName(field, strategy);
209
227
  indexes.push({
210
228
  name: `${tableName}_${colName}_unique`,
211
229
  columns: [colName],
@@ -221,7 +239,7 @@ function buildSecondaryIndexes(entity: MetaObject, tableName: string): IndexDesc
221
239
  if (fieldNames.length === 0) continue;
222
240
  const cols = fieldNames.map((jsName) => {
223
241
  const field = findField(entity, jsName);
224
- return field ? resolveColumnName(field) : toSnake(jsName);
242
+ return field ? resolveColumnName(field, strategy) : applyColumnNamingStrategy(jsName, strategy);
225
243
  });
226
244
  const uniqueAttr = identity.ownAttr(IDENTITY_ATTR_UNIQUE);
227
245
  indexes.push({
@@ -238,6 +256,7 @@ function buildForeignKeys(
238
256
  tableName: string,
239
257
  resolveTargetTable: (entityName: string) => string | undefined,
240
258
  root: MetaRoot,
259
+ strategy: ColumnNamingStrategy,
241
260
  ): FkDescriptor[] {
242
261
  const fks: FkDescriptor[] = [];
243
262
  for (const refChild of entity.referenceIdentities()) {
@@ -253,7 +272,7 @@ function buildForeignKeys(
253
272
 
254
273
  const fkCols = fkFieldJsNames.map((jsName) => {
255
274
  const fkField = findField(entity, jsName);
256
- return fkField ? resolveColumnName(fkField) : toSnake(jsName);
275
+ return fkField ? resolveColumnName(fkField, strategy) : applyColumnNamingStrategy(jsName, strategy);
257
276
  });
258
277
 
259
278
  // Target columns: prefer explicit multi-field dotted form, else delegate
@@ -261,8 +280,8 @@ function buildForeignKeys(
261
280
  // primary identity → "id" fallback).
262
281
  const explicitTargetFields = refChild.targetFields;
263
282
  const refColumns = explicitTargetFields.length > 1
264
- ? explicitTargetFields.map(toSnake)
265
- : [toSnake(refChild.resolvedTargetPkField(root) ?? "id")];
283
+ ? explicitTargetFields.map((n) => applyColumnNamingStrategy(n, strategy))
284
+ : [applyColumnNamingStrategy(refChild.resolvedTargetPkField(root) ?? "id", strategy)];
266
285
 
267
286
  const { onDelete, onUpdate } = resolveReferentialActions(entity, refChild);
268
287
  const constraintName = `${tableName}_${fkCols[0]}_fk`;
@@ -291,15 +310,17 @@ function buildForeignKeys(
291
310
  * EF OwnsOne pattern: no JSON column for the parent itself; each nested field
292
311
  * becomes `<parent_col>_<nested_col>` in the owning entity's table.
293
312
  */
294
- function flattenObjectField(field: MetaData, root: MetaRoot): ColumnDescriptor[] {
313
+ function flattenObjectField(
314
+ field: MetaData, root: MetaRoot, strategy: ColumnNamingStrategy,
315
+ ): ColumnDescriptor[] {
295
316
  const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
296
317
  if (typeof ref !== "string" || ref.length === 0) return [];
297
318
  const targetObject = root.findObject(ref);
298
319
  if (targetObject === undefined) return [];
299
- const prefix = resolveColumnName(field) + "_";
320
+ const prefix = resolveColumnName(field, strategy) + "_";
300
321
  const cols: ColumnDescriptor[] = [];
301
322
  for (const nested of targetObject.fields()) {
302
- const inner = buildColumn(nested, /* isPk */ false, /* pkGeneration */ undefined);
323
+ const inner = buildColumn(nested, /* isPk */ false, /* pkGeneration */ undefined, strategy);
303
324
  cols.push({ ...inner, name: prefix + inner.name });
304
325
  }
305
326
  return cols;
@@ -317,14 +338,15 @@ function buildColumn(
317
338
  field: MetaData,
318
339
  isPk: boolean,
319
340
  pkGeneration: string | undefined,
341
+ strategy: ColumnNamingStrategy,
320
342
  ): ColumnDescriptor {
321
343
  // Both the @required attr and the validator.required child signal NOT NULL.
322
344
  const fieldIsRequired = isRequired(field);
323
345
  const defaultRaw = field.ownAttr(FIELD_ATTR_DEFAULT);
324
346
 
325
347
  const col: ColumnDescriptor = {
326
- name: resolveColumnName(field),
327
- sqlType: subtypeToSqlType(field.subType),
348
+ name: resolveColumnName(field, strategy),
349
+ sqlType: subtypeToSqlType(field),
328
350
  nullable: !isPk && !fieldIsRequired,
329
351
  };
330
352
 
@@ -345,9 +367,14 @@ function buildColumn(
345
367
  return col;
346
368
  }
347
369
 
348
- function subtypeToSqlType(subType: string): SqlType {
370
+ function subtypeToSqlType(field: MetaData): SqlType {
371
+ const subType = field.subType;
349
372
  switch (subType) {
350
- case FIELD_SUBTYPE_STRING: return { kind: "text" };
373
+ case FIELD_SUBTYPE_STRING: {
374
+ // @maxLength is declared as ATTR_SUBTYPE_INT so the loader coerces it to a number.
375
+ const m = field.ownAttr(FIELD_ATTR_MAX_LENGTH);
376
+ return typeof m === "number" ? { kind: "text", maxLength: m } : { kind: "text" };
377
+ }
351
378
  case FIELD_SUBTYPE_INT:
352
379
  case FIELD_SUBTYPE_SHORT:
353
380
  case FIELD_SUBTYPE_BYTE: return { kind: "integer", bits: 32 };
@@ -366,9 +393,3 @@ function subtypeToSqlType(subType: string): SqlType {
366
393
  }
367
394
  }
368
395
 
369
- function toSnake(s: string): string {
370
- return s
371
- .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
372
- .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
373
- .toLowerCase();
374
- }
@@ -0,0 +1,175 @@
1
+ // expected-views.ts — derive ViewDescriptor[] from projection metadata.
2
+ //
3
+ // Ports the shape of csharp/MetaObjects.Codegen/Schema/PostgresSchema.cs's
4
+ // CreateView. v1 supports:
5
+ //
6
+ // * passthrough origin (no @via) → plain column from the single base entity
7
+ // * aggregate origin (@agg/@of/@via) → correlated subquery over a to-many
8
+ //
9
+ // Deferred to v2 (returns a "-- TODO" view that the runner skips applying):
10
+ //
11
+ // * passthrough origin WITH @via → to-one correlated subquery
12
+ // * collection origin → json_agg over a to-many
13
+ // * multi-base projections → blocked
14
+ //
15
+ // Identifiers are quoted throughout so mixed-case column names (e.g.
16
+ // "programId") survive PG's case-folding pass.
17
+
18
+ import type { MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
19
+ import {
20
+ type ColumnNamingStrategy,
21
+ MetaPassthroughOrigin,
22
+ MetaAggregateOrigin,
23
+ MetaCollectionOrigin,
24
+ MetaSource,
25
+ SOURCE_ROLE_PRIMARY,
26
+ TYPE_OBJECT,
27
+ TYPE_ORIGIN,
28
+ resolveColumnName,
29
+ resolveTableName,
30
+ stripPackage,
31
+ } from "@metaobjectsdev/metadata";
32
+ import type { ViewDescriptor } from "./types.js";
33
+
34
+ export function buildExpectedViews(root: MetaRoot, strategy: ColumnNamingStrategy): ViewDescriptor[] {
35
+ const out: ViewDescriptor[] = [];
36
+ for (const child of root.ownChildren()) {
37
+ if (child.type !== TYPE_OBJECT) continue;
38
+ const proj = child as MetaObject;
39
+ if (proj.isAbstract) continue;
40
+ if (!isReadOnlyProjection(proj)) continue;
41
+ const view = buildView(proj, root, strategy);
42
+ if (view !== null) out.push(view);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ function isReadOnlyProjection(entity: MetaObject): boolean {
48
+ const sources = entity.ownChildren().filter((c): c is MetaSource => c instanceof MetaSource);
49
+ if (sources.length === 0) return false;
50
+ const hasReadOnly = sources.some((s) => s.isReadOnly());
51
+ const hasWritable = sources.some((s) => !s.isReadOnly());
52
+ return hasReadOnly && !hasWritable;
53
+ }
54
+
55
+ function buildView(
56
+ projection: MetaObject, root: MetaRoot, strategy: ColumnNamingStrategy,
57
+ ): ViewDescriptor | null {
58
+ const primarySource = projection.ownChildren().find(
59
+ (c): c is MetaSource => c instanceof MetaSource && c.role === SOURCE_ROLE_PRIMARY,
60
+ );
61
+ if (primarySource?.tableName === undefined) return null;
62
+ const viewName = primarySource.tableName;
63
+
64
+ const cols: string[] = [];
65
+ let baseEntity: string | undefined;
66
+ let blocked: string | undefined;
67
+ const T = "t"; // target alias in correlated subqueries
68
+
69
+ const baseTable = () => {
70
+ if (baseEntity === undefined) return undefined;
71
+ const found = root.findObject(baseEntity);
72
+ return found ? resolveTableName(found) : baseEntity;
73
+ };
74
+
75
+ for (const f of projection.fields()) {
76
+ const origin = f.ownChildren().find((c) => c.type === TYPE_ORIGIN);
77
+ const fieldCol = resolveColumnName(f, strategy);
78
+
79
+ if (origin instanceof MetaPassthroughOrigin && origin.via === undefined &&
80
+ origin.from !== undefined && origin.from.includes(".")) {
81
+ const [ent, field] = splitDot(origin.from);
82
+ const bare = stripPackage(ent);
83
+ baseEntity ??= bare;
84
+ if (bare !== baseEntity) { blocked = "passthrough from multiple base entities"; break; }
85
+ const srcEntity = root.findObject(baseEntity);
86
+ const srcCol = resolveColumnByName(srcEntity, field, strategy);
87
+ cols.push(` "${srcCol}" AS "${fieldCol}"`);
88
+ } else if (origin instanceof MetaAggregateOrigin && origin.agg !== undefined &&
89
+ origin.of !== undefined && origin.via !== undefined &&
90
+ origin.of.includes(".") && origin.via.includes(".")) {
91
+ const [baseEnt, relName] = splitDot(origin.via);
92
+ const bareBase = stripPackage(baseEnt);
93
+ baseEntity ??= bareBase;
94
+ if (bareBase !== baseEntity) { blocked = "aggregate over a different base entity"; break; }
95
+ const fk = resolveToManyFk(root, baseEntity, relName, strategy);
96
+ if (fk === null) {
97
+ blocked = `unresolved to-many FK for @via "${origin.via}" (target needs an identity.reference back to ${baseEntity})`;
98
+ break;
99
+ }
100
+ const ofCol = resolveColumnByName(fk.target, splitDot(origin.of)[1], strategy);
101
+ cols.push(
102
+ ` (SELECT ${origin.agg}(${T}."${ofCol}") ` +
103
+ `FROM "${fk.targetTable}" ${T} ` +
104
+ `WHERE ${T}."${fk.fkCol}" = "${baseTable()}"."${fk.parentCol}") AS "${fieldCol}"`,
105
+ );
106
+ } else if (origin instanceof MetaPassthroughOrigin || origin instanceof MetaCollectionOrigin) {
107
+ blocked = `field "${f.name}" uses an origin shape not yet supported by TS migrate-ts (passthrough-via / collection — deferred)`;
108
+ break;
109
+ } else {
110
+ blocked = `field "${f.name}" has no resolvable origin`;
111
+ break;
112
+ }
113
+ }
114
+
115
+ if (blocked !== undefined || baseEntity === undefined || cols.length === 0) {
116
+ // Caller decides what to do with a null result. For v1 the runner just
117
+ // omits it from expected.views — diff sees no expected view, no
118
+ // create-view change, and an actual leftover view (if any) gets dropped.
119
+ return null;
120
+ }
121
+
122
+ const sql = `SELECT\n${cols.join(",\n")}\nFROM "${baseTable()}"`;
123
+ const view: ViewDescriptor = { name: viewName, sql };
124
+ return view;
125
+ }
126
+
127
+ function splitDot(s: string): [string, string] {
128
+ const i = s.indexOf(".");
129
+ return [s.slice(0, i), s.slice(i + 1)];
130
+ }
131
+
132
+ function resolveColumnByName(
133
+ owner: MetaObject | undefined, fieldName: string, strategy: ColumnNamingStrategy,
134
+ ): string {
135
+ if (owner === undefined) return fieldName;
136
+ const field = owner.fields().find((f) => f.name === fieldName);
137
+ return field ? resolveColumnName(field, strategy) : fieldName;
138
+ }
139
+
140
+ interface ToManyFk {
141
+ target: MetaObject;
142
+ targetTable: string;
143
+ /** Column on the target entity holding the FK back to the base. */
144
+ fkCol: string;
145
+ /** Column on the base entity the FK references (usually the base's PK). */
146
+ parentCol: string;
147
+ }
148
+
149
+ function resolveToManyFk(
150
+ root: MetaRoot, baseEntityName: string, relName: string, strategy: ColumnNamingStrategy,
151
+ ): ToManyFk | null {
152
+ const baseObj = root.findObject(baseEntityName);
153
+ if (baseObj === undefined) return null;
154
+ const rel = baseObj.relationships().find((r) => r.name === relName);
155
+ if (rel === undefined || rel.objectRef === undefined) return null;
156
+ const target = root.findObject(stripPackage(rel.objectRef));
157
+ if (target === undefined) return null;
158
+
159
+ const fkRef = target.referenceIdentities().find(
160
+ (r) => r.targetEntity !== undefined && stripPackage(r.targetEntity) === baseEntityName,
161
+ );
162
+ if (fkRef === undefined) return null;
163
+ const fkFields = fkRef.fields;
164
+ if (fkFields.length === 0) return null;
165
+ const fkCol = resolveColumnByName(target, fkFields[0]!, strategy);
166
+
167
+ const parentFieldName = fkRef.targetFields.length > 0
168
+ ? fkRef.targetFields[0]!
169
+ : baseObj.primaryIdentity()?.fields[0];
170
+ if (parentFieldName === undefined) return null;
171
+ const parentCol = resolveColumnByName(baseObj, parentFieldName, strategy);
172
+
173
+ return { target, targetTable: resolveTableName(target), fkCol, parentCol };
174
+ }
175
+
@@ -33,10 +33,20 @@ export async function introspectD1(opts: IntrospectD1Options): Promise<SchemaSna
33
33
  return parseEnvelope(stdout);
34
34
  };
35
35
 
36
- const versionRows = await exec("SELECT sqlite_version() AS v");
37
- const meta: SnapshotMeta = {
38
- sqliteVersion: String(versionRows[0]?.v ?? "0.0.0"),
39
- };
36
+ // sqlite_version() is blocked by workerd's local D1 sandbox, so we fall back
37
+ // to a known-good static version (Cloudflare D1 ships a recent SQLite). Remote
38
+ // wrangler executions answer the function, so we try once and only fall back
39
+ // on failure. Keep this string ≥ any version-gated downstream feature checks
40
+ // (see emit/sqlite.ts → parseVersion).
41
+ let sqliteVersion = "3.44.0";
42
+ try {
43
+ const versionRows = await exec("SELECT sqlite_version() AS v");
44
+ const v = versionRows[0]?.v;
45
+ if (v !== undefined && v !== null) sqliteVersion = String(v);
46
+ } catch {
47
+ // Fall through to the static default — workerd local sandbox path.
48
+ }
49
+ const meta: SnapshotMeta = { sqliteVersion };
40
50
 
41
51
  const tableRows = await exec(
42
52
  "SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__new_%' ORDER BY name",
package/src/types.ts CHANGED
@@ -82,7 +82,14 @@ export interface ViewDescriptor {
82
82
  name: string;
83
83
  /** Same semantics as TableDescriptor.schema. */
84
84
  schema?: string;
85
- // structural fields deferred to v0.3
85
+ /**
86
+ * View body: everything between `CREATE VIEW <name> AS` and the trailing `;`
87
+ * (the SELECT clause through the FROM/WHERE/GROUP-BY tail). Populated by
88
+ * `buildExpectedSchema` from projection metadata; omitted by introspect
89
+ * (body-level comparison isn't implemented yet — diff matches by name only,
90
+ * so a body change does NOT trigger replace-view today).
91
+ */
92
+ sql?: string;
86
93
  }
87
94
 
88
95
  // ---------------------------------------------------------------------------
@@ -118,9 +125,9 @@ export type Change =
118
125
  | { kind: "add-fk"; table: string; schema?: string; fk: FkDescriptor; status: ChangeStatus }
119
126
  | { kind: "drop-fk"; table: string; schema?: string; fk: string; status: ChangeStatus }
120
127
  // Declared for v0.3, never produced in v0.1:
121
- | { kind: "create-view"; view: ViewDescriptor; status: ChangeStatus }
122
- | { kind: "drop-view"; view: string; status: ChangeStatus }
123
- | { kind: "replace-view"; view: ViewDescriptor; status: ChangeStatus };
128
+ | { kind: "create-view"; view: ViewDescriptor; schema?: string; status: ChangeStatus }
129
+ | { kind: "drop-view"; view: string; schema?: string; status: ChangeStatus }
130
+ | { kind: "replace-view"; view: ViewDescriptor; schema?: string; status: ChangeStatus };
124
131
 
125
132
  export type ChangeKind = Change["kind"];
126
133