kintone-migrator 0.31.6 → 0.32.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.
package/dist/index.mjs CHANGED
@@ -67,7 +67,10 @@ const FormSchemaErrorCode = {
67
67
  FsInvalidFieldType: "FS_INVALID_FIELD_TYPE",
68
68
  FsInvalidLayoutStructure: "FS_INVALID_LAYOUT_STRUCTURE",
69
69
  FsInvalidDecorationElement: "FS_INVALID_DECORATION_ELEMENT",
70
- FsEmptyFields: "FS_EMPTY_FIELDS"
70
+ FsEmptyFields: "FS_EMPTY_FIELDS",
71
+ FsInvalidStateStructure: "FS_INVALID_STATE_STRUCTURE",
72
+ FsInvalidMergeResolution: "FS_INVALID_MERGE_RESOLUTION",
73
+ FsOrphanMergedField: "FS_ORPHAN_MERGED_FIELD"
71
74
  };
72
75
  //#endregion
73
76
  //#region src/core/domain/generalSettings/errorCode.ts
@@ -226,7 +229,10 @@ var NotFoundError = class extends ApplicationError {
226
229
  function isNotFoundError(error) {
227
230
  return error instanceof NotFoundError;
228
231
  }
229
- const ConflictErrorCode = { Conflict: "CONFLICT" };
232
+ const ConflictErrorCode = {
233
+ Conflict: "CONFLICT",
234
+ SchemaDrift: "SCHEMA_DRIFT"
235
+ };
230
236
  var ConflictError = class extends ApplicationError {
231
237
  name = "ConflictError";
232
238
  constructor(code, message, cause) {
@@ -668,6 +674,68 @@ function buildDiffResult(entries, warnings = []) {
668
674
  warnings
669
675
  };
670
676
  }
677
+ /**
678
+ * Classifies every key present in any of base/local/remote into a 3-way change.
679
+ *
680
+ * Pure and domain-agnostic: the caller supplies the equality function `eq`
681
+ * and the value type `V`. Presence/absence of a key is treated as a value, so
682
+ * `eq` is only ever called with two defined values; an `undefined` on one side
683
+ * (absence) compared to a defined value (presence) is always "changed".
684
+ */
685
+ function classifyThreeWay(base, local, remote, eq) {
686
+ const keys = new Set([
687
+ ...base.keys(),
688
+ ...local.keys(),
689
+ ...remote.keys()
690
+ ]);
691
+ const entries = [];
692
+ const conflicts = [];
693
+ const sidesEqual = (a, b) => {
694
+ if (a === void 0 && b === void 0) return true;
695
+ if (a === void 0 || b === void 0) return false;
696
+ return eq(a, b);
697
+ };
698
+ for (const key of keys) {
699
+ const baseValue = base.get(key);
700
+ const localValue = local.get(key);
701
+ const remoteValue = remote.get(key);
702
+ const dl = !sidesEqual(baseValue, localValue);
703
+ const dr = !sidesEqual(baseValue, remoteValue);
704
+ let change;
705
+ let merged;
706
+ if (!dl && !dr) {
707
+ change = { kind: "unchanged" };
708
+ merged = baseValue;
709
+ } else if (dl && !dr) {
710
+ change = { kind: "localOnly" };
711
+ merged = localValue;
712
+ } else if (!dl && dr) {
713
+ change = { kind: "remoteOnly" };
714
+ merged = remoteValue;
715
+ } else if (sidesEqual(localValue, remoteValue)) {
716
+ change = { kind: "bothSame" };
717
+ merged = localValue;
718
+ } else {
719
+ change = { kind: "conflict" };
720
+ merged = void 0;
721
+ }
722
+ const entry = {
723
+ key,
724
+ change,
725
+ ...baseValue !== void 0 ? { base: baseValue } : {},
726
+ ...localValue !== void 0 ? { local: localValue } : {},
727
+ ...remoteValue !== void 0 ? { remote: remoteValue } : {},
728
+ ...merged !== void 0 ? { merged } : {}
729
+ };
730
+ entries.push(entry);
731
+ if (change.kind === "conflict") conflicts.push(entry);
732
+ }
733
+ return {
734
+ entries,
735
+ conflicts,
736
+ hasConflict: conflicts.length > 0
737
+ };
738
+ }
671
739
  //#endregion
672
740
  //#region src/core/domain/services/formatValue.ts
673
741
  function formatValue(v) {
@@ -1188,6 +1256,16 @@ var KintoneFileUploader = class {
1188
1256
  }
1189
1257
  };
1190
1258
  //#endregion
1259
+ //#region src/core/domain/formSchema/ports/formConfigurator.ts
1260
+ /**
1261
+ * Sentinel passed as `expectedRevision` to explicitly skip the kintone revision
1262
+ * check (sends `-1` / omits the expected revision). Used by `--force` push so
1263
+ * that the apply succeeds even when the remote drifted (AC-9 / ADR-005 /
1264
+ * ADR-010). This is distinct from `undefined`, which means "use the adapter's
1265
+ * default tracked revision" (existing migrate behaviour).
1266
+ */
1267
+ const SKIP_REVISION_CHECK = Symbol("skip-revision-check");
1268
+ //#endregion
1191
1269
  //#region src/lib/charValidation.ts
1192
1270
  /** Returns true if the string contains any control characters (0x00–0x1f or 0x7f). */
1193
1271
  function hasControlChars(s) {
@@ -1568,40 +1646,60 @@ var KintoneFormConfigurator = class {
1568
1646
  throw wrapKintoneError(error, "Failed to get form fields");
1569
1647
  }
1570
1648
  }
1571
- async addFields(fields) {
1649
+ async getRevision() {
1650
+ try {
1651
+ const { revision } = await this.client.app.getFormFields({
1652
+ app: this.appId,
1653
+ preview: true
1654
+ });
1655
+ if (!revision) throw new SystemError(SystemErrorCode.ExternalApiError, "kintone API did not return a form revision");
1656
+ this.revisionTracker.track(revision);
1657
+ return revision;
1658
+ } catch (error) {
1659
+ throw wrapKintoneError(error, "Failed to get form revision");
1660
+ }
1661
+ }
1662
+ resolveMutationRevision(expectedRevision) {
1663
+ if (expectedRevision === SKIP_REVISION_CHECK) return null;
1664
+ return expectedRevision ?? this.revisionTracker.current ?? null;
1665
+ }
1666
+ async addFields(fields, expectedRevision) {
1572
1667
  try {
1573
1668
  const properties = {};
1574
1669
  for (const field of fields) properties[field.code] = toKintoneProperty(field);
1670
+ const revision = this.resolveMutationRevision(expectedRevision);
1575
1671
  const response = await this.client.app.addFormFields({
1576
1672
  app: this.appId,
1577
1673
  properties,
1578
- ...this.revisionTracker.current !== void 0 ? { revision: this.revisionTracker.current } : {}
1674
+ ...revision !== null ? { revision } : {}
1579
1675
  });
1580
1676
  if (response.revision) this.revisionTracker.track(response.revision);
1581
1677
  } catch (error) {
1582
1678
  throw wrapKintoneError(error, "Failed to add form fields");
1583
1679
  }
1584
1680
  }
1585
- async updateFields(fields) {
1681
+ async updateFields(fields, expectedRevision) {
1586
1682
  try {
1587
1683
  const properties = {};
1588
1684
  for (const field of fields) properties[field.code] = toKintoneProperty(field);
1685
+ const revision = this.resolveMutationRevision(expectedRevision);
1589
1686
  const response = await this.client.app.updateFormFields({
1590
1687
  app: this.appId,
1591
1688
  properties,
1592
- ...this.revisionTracker.current !== void 0 ? { revision: this.revisionTracker.current } : {}
1689
+ ...revision !== null ? { revision } : {}
1593
1690
  });
1594
1691
  if (response.revision) this.revisionTracker.track(response.revision);
1595
1692
  } catch (error) {
1596
1693
  throw wrapKintoneError(error, "Failed to update form fields");
1597
1694
  }
1598
1695
  }
1599
- async deleteFields(fieldCodes) {
1696
+ async deleteFields(fieldCodes, expectedRevision) {
1600
1697
  try {
1698
+ const revision = this.resolveMutationRevision(expectedRevision);
1601
1699
  const response = await this.client.app.deleteFormFields({
1602
1700
  app: this.appId,
1603
1701
  fields: fieldCodes.map((code) => code),
1604
- ...this.revisionTracker.current !== void 0 ? { revision: this.revisionTracker.current } : {}
1702
+ ...revision !== null ? { revision } : {}
1605
1703
  });
1606
1704
  if (response.revision) this.revisionTracker.track(response.revision);
1607
1705
  } catch (error) {
@@ -1620,13 +1718,14 @@ var KintoneFormConfigurator = class {
1620
1718
  throw wrapKintoneError(error, "Failed to get form layout");
1621
1719
  }
1622
1720
  }
1623
- async updateLayout(layout) {
1721
+ async updateLayout(layout, expectedRevision) {
1624
1722
  try {
1625
1723
  const kintoneLayout = layout.map(toKintoneLayoutItem);
1724
+ const revision = this.resolveMutationRevision(expectedRevision);
1626
1725
  const response = await this.client.app.updateFormLayout({
1627
1726
  app: this.appId,
1628
1727
  layout: kintoneLayout,
1629
- revision: this.revisionTracker.current !== void 0 ? Number(this.revisionTracker.current) : -1
1728
+ revision: revision !== null ? Number(revision) : -1
1630
1729
  });
1631
1730
  if (response.revision) this.revisionTracker.track(response.revision);
1632
1731
  } catch (error) {
@@ -1831,6 +1930,11 @@ var LocalFileWriter = class {
1831
1930
  }
1832
1931
  };
1833
1932
  //#endregion
1933
+ //#region src/core/adapters/local/schemaStateStorage.ts
1934
+ function createLocalFileSchemaStateStorage(filePath) {
1935
+ return createLocalFileStorage(filePath, "schema state file");
1936
+ }
1937
+ //#endregion
1834
1938
  //#region src/core/adapters/local/schemaStorage.ts
1835
1939
  function createLocalFileSchemaStorage(filePath) {
1836
1940
  return createLocalFileStorage(filePath, "schema file");
@@ -1855,6 +1959,7 @@ function createCliContainer(config) {
1855
1959
  configCodec,
1856
1960
  formConfigurator: new KintoneFormConfigurator(client, config.appId),
1857
1961
  schemaStorage: createLocalFileSchemaStorage(config.schemaFilePath),
1962
+ schemaStateStorage: createLocalFileSchemaStateStorage(config.stateSchemaFilePath),
1858
1963
  appDeployer: new KintoneAppDeployer(client, config.appId)
1859
1964
  };
1860
1965
  }
@@ -1899,6 +2004,48 @@ function createActionCliContainer(config) {
1899
2004
  };
1900
2005
  }
1901
2006
  //#endregion
2007
+ //#region src/core/domain/projectConfig/appFilePaths.ts
2008
+ function buildAppFilePaths(appName, baseDir) {
2009
+ const prefix = (path) => baseDir ? join(baseDir, path) : path;
2010
+ return {
2011
+ schema: prefix(`${appName}/schema.yaml`),
2012
+ seed: prefix(`${appName}/seed.yaml`),
2013
+ customize: prefix(`${appName}/customize.yaml`),
2014
+ view: prefix(`${appName}/view.yaml`),
2015
+ settings: prefix(`${appName}/settings.yaml`),
2016
+ notification: prefix(`${appName}/notification.yaml`),
2017
+ report: prefix(`${appName}/report.yaml`),
2018
+ action: prefix(`${appName}/action.yaml`),
2019
+ process: prefix(`${appName}/process.yaml`),
2020
+ fieldAcl: prefix(`${appName}/field-acl.yaml`),
2021
+ appAcl: prefix(`${appName}/app-acl.yaml`),
2022
+ recordAcl: prefix(`${appName}/record-acl.yaml`),
2023
+ adminNotes: prefix(`${appName}/admin-notes.yaml`),
2024
+ plugin: prefix(`${appName}/plugin.yaml`)
2025
+ };
2026
+ }
2027
+ /**
2028
+ * Resolves the path to the schema state (base snapshot) file.
2029
+ *
2030
+ * State uses an app-scoped directory layout (`state/<appName>/schema.yaml`)
2031
+ * whose hierarchy is the inverse of {@link buildAppFilePaths} (`<appName>/...`).
2032
+ * They are intentionally kept as separate functions: the state convention is
2033
+ * chosen so that future per-app revision unification (e.g.
2034
+ * `state/<appName>/revision.yaml`) can live alongside without churn.
2035
+ */
2036
+ function buildStateFilePath(appName, baseDir) {
2037
+ const path = `state/${appName}/schema.yaml`;
2038
+ return baseDir ? join(baseDir, path) : path;
2039
+ }
2040
+ /**
2041
+ * Resolves the path to the legacy single-app schema state file
2042
+ * (`state/schema.yaml`).
2043
+ */
2044
+ function buildLegacyStateFilePath(baseDir) {
2045
+ const path = "state/schema.yaml";
2046
+ return baseDir ? join(baseDir, path) : path;
2047
+ }
2048
+ //#endregion
1902
2049
  //#region src/cli/config.ts
1903
2050
  const CliConfigSchema = v.object({
1904
2051
  KINTONE_DOMAIN: v.pipe(v.string(), v.nonEmpty("KINTONE_DOMAIN is required")),
@@ -2001,7 +2148,8 @@ function resolveConfig(cliValues) {
2001
2148
  auth,
2002
2149
  appId: output.KINTONE_APP_ID,
2003
2150
  guestSpaceId: output.KINTONE_GUEST_SPACE_ID,
2004
- schemaFilePath: output.SCHEMA_FILE_PATH
2151
+ schemaFilePath: output.SCHEMA_FILE_PATH,
2152
+ stateSchemaFilePath: buildLegacyStateFilePath()
2005
2153
  };
2006
2154
  }
2007
2155
  function resolveAuth(apiToken, username, password) {
@@ -2254,27 +2402,6 @@ function resolveSingleApp(config, appName) {
2254
2402
  return { orderedApps: [entry] };
2255
2403
  }
2256
2404
  //#endregion
2257
- //#region src/core/domain/projectConfig/appFilePaths.ts
2258
- function buildAppFilePaths(appName, baseDir) {
2259
- const prefix = (path) => baseDir ? join(baseDir, path) : path;
2260
- return {
2261
- schema: prefix(`${appName}/schema.yaml`),
2262
- seed: prefix(`${appName}/seed.yaml`),
2263
- customize: prefix(`${appName}/customize.yaml`),
2264
- view: prefix(`${appName}/view.yaml`),
2265
- settings: prefix(`${appName}/settings.yaml`),
2266
- notification: prefix(`${appName}/notification.yaml`),
2267
- report: prefix(`${appName}/report.yaml`),
2268
- action: prefix(`${appName}/action.yaml`),
2269
- process: prefix(`${appName}/process.yaml`),
2270
- fieldAcl: prefix(`${appName}/field-acl.yaml`),
2271
- appAcl: prefix(`${appName}/app-acl.yaml`),
2272
- recordAcl: prefix(`${appName}/record-acl.yaml`),
2273
- adminNotes: prefix(`${appName}/admin-notes.yaml`),
2274
- plugin: prefix(`${appName}/plugin.yaml`)
2275
- };
2276
- }
2277
- //#endregion
2278
2405
  //#region src/cli/handleError.ts
2279
2406
  /**
2280
2407
  * Log error details without terminating the process.
@@ -2481,6 +2608,27 @@ function printDiffResult(result) {
2481
2608
  });
2482
2609
  p.note(lines.join("\n"), "Diff Details", { format: (v) => v });
2483
2610
  }
2611
+ function printThreeWayDiffResult(result) {
2612
+ if (result.mode === "two-way") {
2613
+ p.log.info("No base snapshot found (2-way comparison).");
2614
+ printDiffResult(result.diff);
2615
+ return;
2616
+ }
2617
+ if (result.isEmpty) {
2618
+ p.log.info("No changes detected (local, remote, and base are in sync).");
2619
+ return;
2620
+ }
2621
+ const lines = [];
2622
+ for (const e of result.localChanges) lines.push(`${pc.green("L")} ${pc.dim("[")}${pc.green(e.fieldCode)}${pc.dim("]")} ${e.fieldLabel}${pc.dim(":")} local change`);
2623
+ for (const e of result.remoteDrift) lines.push(`${pc.yellow("R")} ${pc.dim("[")}${pc.yellow(e.fieldCode)}${pc.dim("]")} ${e.fieldLabel}${pc.dim(":")} remote drift`);
2624
+ for (const e of result.conflicts) lines.push(`${pc.red("C")} ${pc.dim("[")}${pc.red(e.fieldCode)}${pc.dim("]")} ${e.fieldLabel}${pc.dim(":")} conflict`);
2625
+ if (result.layoutConflict) lines.push(`${pc.red("C")} ${pc.red("layout")}${pc.dim(":")} conflict`);
2626
+ else if (result.layoutLocalChanged && !result.layoutRemoteChanged) lines.push(`${pc.green("L")} ${pc.green("layout")}${pc.dim(":")} local change`);
2627
+ else if (result.layoutRemoteChanged && !result.layoutLocalChanged) lines.push(`${pc.yellow("R")} ${pc.yellow("layout")}${pc.dim(":")} remote drift`);
2628
+ else if (result.layoutLocalChanged && result.layoutRemoteChanged) lines.push(`${pc.green("L")} ${pc.green("layout")}${pc.dim(":")} change`);
2629
+ p.log.info(`${pc.green("L")}=local ${pc.yellow("R")}=remote drift ${pc.red("C")}=conflict`);
2630
+ p.note(lines.join("\n"), "3-way Diff Details", { format: (v) => v });
2631
+ }
2484
2632
  function printFieldPermissionDiffResult(result) {
2485
2633
  printGenericDiffResult(result, "Field Permission Diff Details", (entry, colorize, prefix) => `${colorize(prefix)} ${pc.dim("[")}${colorize(entry.fieldCode)}${pc.dim("]:")} ${entry.details}`);
2486
2634
  }
@@ -2603,7 +2751,8 @@ function resolveAppCliConfig(app, projectConfig, cliValues) {
2603
2751
  auth,
2604
2752
  appId: app.appId,
2605
2753
  guestSpaceId,
2606
- schemaFilePath: cliValues["schema-file"] ?? process.env.SCHEMA_FILE_PATH ?? app.schemaFile ?? buildAppFilePaths(app.name).schema
2754
+ schemaFilePath: cliValues["schema-file"] ?? process.env.SCHEMA_FILE_PATH ?? app.schemaFile ?? buildAppFilePaths(app.name).schema,
2755
+ stateSchemaFilePath: buildStateFilePath(app.name)
2607
2756
  };
2608
2757
  }
2609
2758
  function resolveAuthForApp(app, projectConfig, cliValues) {
@@ -4072,6 +4221,65 @@ function splitSubtableInnerFields(desired, current) {
4072
4221
  };
4073
4222
  }
4074
4223
  //#endregion
4224
+ //#region src/core/application/formSchema/applySchemaChanges.ts
4225
+ function processModifiedEntry(after, before, fieldsToUpdate, innerFieldsToDelete) {
4226
+ if (after.type === "SUBTABLE" && before !== void 0 && before.type === "SUBTABLE") {
4227
+ const { newInnerFields, existingInnerFields, deletedInnerFieldCodes } = splitSubtableInnerFields(after, before);
4228
+ if (newInnerFields.size > 0) throw new ValidationError(ValidationErrorCode.InvalidInput, `kintone REST API does not support adding fields to an existing subtable. Use the schema override command instead. Subtable: ${after.code}`);
4229
+ if (existingInnerFields.size > 0) fieldsToUpdate.push({
4230
+ ...after,
4231
+ properties: { fields: existingInnerFields }
4232
+ });
4233
+ for (const code of deletedInnerFieldCodes) innerFieldsToDelete.push(code);
4234
+ } else if (before !== void 0 && before.type !== after.type) throw new ValidationError(ValidationErrorCode.InvalidInput, `Field type change detected for "${after.code}" (${before.type} → ${after.type}). Use the schema override command instead.`);
4235
+ else fieldsToUpdate.push(after);
4236
+ }
4237
+ /**
4238
+ * Applies a {@link Schema} to the remote form (preview).
4239
+ *
4240
+ * This is the shared application core (ADR-009): it computes the diff against
4241
+ * the current form, classifies
4242
+ * add/update/delete/layout changes, rejects field type changes and additions
4243
+ * to existing subtables with a {@link ValidationError} (AC-13), and applies the
4244
+ * changes via the configurator. `migrate` (via local YAML) and `push` (via a
4245
+ * merged/local in-memory `Schema`) share this exact implementation.
4246
+ *
4247
+ * When `expectedRevision` is provided it is passed to every mutation so a
4248
+ * concurrent change yields a 409 conflict.
4249
+ */
4250
+ async function applySchemaChanges(schema, { container, expectedRevision }) {
4251
+ const [currentFields, currentLayout] = await Promise.all([container.formConfigurator.getFields(), container.formConfigurator.getLayout()]);
4252
+ const diff = DiffDetector.detect(schema, currentFields);
4253
+ const enrichedCurrentLayout = enrichLayoutWithFields(currentLayout, currentFields);
4254
+ const hasLayoutChanges = DiffDetector.detectLayoutChanges(schema.layout, enrichedCurrentLayout);
4255
+ if (diff.isEmpty && !hasLayoutChanges) return;
4256
+ const subtableInnerCodes = collectSubtableInnerFieldCodes(schema.fields);
4257
+ const added = diff.entries.filter((e) => e.type === "added");
4258
+ const modified = diff.entries.filter((e) => e.type === "modified");
4259
+ const deleted = diff.entries.filter((e) => e.type === "deleted");
4260
+ const fieldsToAdd = [];
4261
+ const fieldsToUpdate = [];
4262
+ const innerFieldsToDelete = [];
4263
+ for (const entry of added) {
4264
+ if (entry.after === void 0) continue;
4265
+ if (subtableInnerCodes.has(entry.fieldCode)) continue;
4266
+ fieldsToAdd.push(entry.after);
4267
+ }
4268
+ for (const entry of modified) {
4269
+ if (entry.after === void 0) continue;
4270
+ if (subtableInnerCodes.has(entry.fieldCode)) continue;
4271
+ processModifiedEntry(entry.after, entry.before, fieldsToUpdate, innerFieldsToDelete);
4272
+ }
4273
+ if (fieldsToAdd.length > 0) await container.formConfigurator.addFields(fieldsToAdd, expectedRevision);
4274
+ if (fieldsToUpdate.length > 0) await container.formConfigurator.updateFields(fieldsToUpdate, expectedRevision);
4275
+ if (deleted.length > 0 || innerFieldsToDelete.length > 0) {
4276
+ const currentSubtableInnerCodes = collectSubtableInnerFieldCodes(currentFields);
4277
+ const fieldCodes = [...deleted.filter((e) => !currentSubtableInnerCodes.has(e.fieldCode)).map((e) => e.fieldCode), ...innerFieldsToDelete];
4278
+ if (fieldCodes.length > 0) await container.formConfigurator.deleteFields(fieldCodes, expectedRevision);
4279
+ }
4280
+ if (hasLayoutChanges) await container.formConfigurator.updateLayout(schema.layout, expectedRevision);
4281
+ }
4282
+ //#endregion
4075
4283
  //#region src/core/domain/formSchema/services/schemaValidator.ts
4076
4284
  const SELECTION_TYPES = new Set([
4077
4285
  "CHECK_BOX",
@@ -4698,53 +4906,20 @@ function parseSchemaText(codec, rawText) {
4698
4906
  }
4699
4907
  //#endregion
4700
4908
  //#region src/core/application/formSchema/executeMigration.ts
4701
- function processModifiedEntry(after, before, fieldsToUpdate, innerFieldsToDelete) {
4702
- if (after.type === "SUBTABLE" && before !== void 0 && before.type === "SUBTABLE") {
4703
- const { newInnerFields, existingInnerFields, deletedInnerFieldCodes } = splitSubtableInnerFields(after, before);
4704
- if (newInnerFields.size > 0) throw new ValidationError(ValidationErrorCode.InvalidInput, `kintone REST API does not support adding fields to an existing subtable. Use the schema override command instead. Subtable: ${after.code}`);
4705
- if (existingInnerFields.size > 0) fieldsToUpdate.push({
4706
- ...after,
4707
- properties: { fields: existingInnerFields }
4708
- });
4709
- for (const code of deletedInnerFieldCodes) innerFieldsToDelete.push(code);
4710
- } else if (before !== void 0 && before.type !== after.type) throw new ValidationError(ValidationErrorCode.InvalidInput, `Field type change detected for "${after.code}" (${before.type} → ${after.type}). Use the schema override command instead.`);
4711
- else fieldsToUpdate.push(after);
4712
- }
4909
+ /**
4910
+ * Reads the local schema YAML and applies it to the remote form.
4911
+ *
4912
+ * Thin wrapper around {@link applySchemaChanges} (ADR-009): it loads/parses the
4913
+ * local schema file and validates it, then delegates the application core to
4914
+ * the shared function. It does not enforce an expected revision (legacy migrate
4915
+ * behaviour) and does not read or write the state file.
4916
+ */
4713
4917
  async function executeMigration({ container }) {
4714
4918
  const result = await container.schemaStorage.get();
4715
4919
  if (!result.exists) throw new ValidationError(ValidationErrorCode.InvalidInput, "Schema file not found");
4716
4920
  const schema = parseSchemaText(container.configCodec, result.content);
4717
4921
  assertSchemaValid(schema);
4718
- const [currentFields, currentLayout] = await Promise.all([container.formConfigurator.getFields(), container.formConfigurator.getLayout()]);
4719
- const diff = DiffDetector.detect(schema, currentFields);
4720
- const enrichedCurrentLayout = enrichLayoutWithFields(currentLayout, currentFields);
4721
- const hasLayoutChanges = DiffDetector.detectLayoutChanges(schema.layout, enrichedCurrentLayout);
4722
- if (diff.isEmpty && !hasLayoutChanges) return;
4723
- const subtableInnerCodes = collectSubtableInnerFieldCodes(schema.fields);
4724
- const added = diff.entries.filter((e) => e.type === "added");
4725
- const modified = diff.entries.filter((e) => e.type === "modified");
4726
- const deleted = diff.entries.filter((e) => e.type === "deleted");
4727
- const fieldsToAdd = [];
4728
- const fieldsToUpdate = [];
4729
- const innerFieldsToDelete = [];
4730
- for (const entry of added) {
4731
- if (entry.after === void 0) continue;
4732
- if (subtableInnerCodes.has(entry.fieldCode)) continue;
4733
- fieldsToAdd.push(entry.after);
4734
- }
4735
- for (const entry of modified) {
4736
- if (entry.after === void 0) continue;
4737
- if (subtableInnerCodes.has(entry.fieldCode)) continue;
4738
- processModifiedEntry(entry.after, entry.before, fieldsToUpdate, innerFieldsToDelete);
4739
- }
4740
- if (fieldsToAdd.length > 0) await container.formConfigurator.addFields(fieldsToAdd);
4741
- if (fieldsToUpdate.length > 0) await container.formConfigurator.updateFields(fieldsToUpdate);
4742
- if (deleted.length > 0 || innerFieldsToDelete.length > 0) {
4743
- const currentSubtableInnerCodes = collectSubtableInnerFieldCodes(currentFields);
4744
- const fieldCodes = [...deleted.filter((e) => !currentSubtableInnerCodes.has(e.fieldCode)).map((e) => e.fieldCode), ...innerFieldsToDelete];
4745
- if (fieldCodes.length > 0) await container.formConfigurator.deleteFields(fieldCodes);
4746
- }
4747
- if (hasLayoutChanges) await container.formConfigurator.updateLayout(schema.layout);
4922
+ await applySchemaChanges(schema, { container });
4748
4923
  }
4749
4924
  //#endregion
4750
4925
  //#region src/core/domain/generalSettings/services/configParser.ts
@@ -7067,7 +7242,8 @@ function createCliApplyAllContainers(input) {
7067
7242
  const paths = buildAppFilePaths(input.appName, input.baseDir);
7068
7243
  const schema = createCliContainer({
7069
7244
  ...base,
7070
- schemaFilePath: paths.schema
7245
+ schemaFilePath: paths.schema,
7246
+ stateSchemaFilePath: buildStateFilePath(input.appName, input.baseDir)
7071
7247
  });
7072
7248
  const seed = createSeedCliContainer({
7073
7249
  ...base,
@@ -8345,7 +8521,12 @@ const applyArgs = {
8345
8521
  ...confirmArgs,
8346
8522
  ...dryRunArgs
8347
8523
  };
8348
- async function runApplyAll(cliConfig, appName, options) {
8524
+ /**
8525
+ * Diff phase: generate containers, run the diff preview, print results, emit the
8526
+ * seed note, and report whether there are changes. Does not print the dry-run
8527
+ * note — the caller decides when to surface it.
8528
+ */
8529
+ async function runApplyDiffPhase(cliConfig, appName) {
8349
8530
  const { containers, diffContainers, paths } = createCliApplyAllContainers({
8350
8531
  ...cliConfig,
8351
8532
  appName
@@ -8362,7 +8543,43 @@ async function runApplyAll(cliConfig, appName, options) {
8362
8543
  printDiffAllResults(diffResults);
8363
8544
  const seedExists = (await containers.seed.seedStorage.get()).exists;
8364
8545
  if (seedExists) p.log.info("Note: Seed data will be upserted (no diff preview available).");
8365
- const hasChanges = diffResults.some((r) => r.success && !r.result.isEmpty);
8546
+ return {
8547
+ containers,
8548
+ customizeBasePath,
8549
+ seedExists,
8550
+ hasChanges: diffResults.some((r) => r.success && !r.result.isEmpty)
8551
+ };
8552
+ }
8553
+ /**
8554
+ * Apply phase: run apply for one app, print results, and compute the
8555
+ * `AppExecutionOutcome`. Deploy stays embedded in `applyAllForApp`, so this
8556
+ * preserves per-app dependency-ordered deploy even when called per app from the
8557
+ * multi-app flow.
8558
+ */
8559
+ async function runApplyExecutePhase({ containers, customizeBasePath, appName }) {
8560
+ const as = p.spinner();
8561
+ as.start(`Applying all domains for ${appName}...`);
8562
+ const output = await applyAllForApp({
8563
+ containers,
8564
+ customizeBasePath
8565
+ });
8566
+ const applyFailCount = output.phases.flatMap((pr) => pr.results).filter((r) => !r.success && r.skipped !== "not-found").length;
8567
+ as.stop(`Apply complete.${applyFailCount > 0 ? ` (${applyFailCount} failed)` : ""}`);
8568
+ printApplyAllResults(output);
8569
+ if (output.phases.flatMap((pr) => pr.results).some((r) => !r.success && r.skipped !== "not-found") || output.deployError) return {
8570
+ ok: false,
8571
+ error: output.deployError ?? new SystemError(SystemErrorCode.ExecutionError, `Apply failed for ${appName}.`)
8572
+ };
8573
+ return { ok: true };
8574
+ }
8575
+ /**
8576
+ * Single-app wrapper that runs the diff and apply phases in sequence, preserving
8577
+ * the original single-mode flow (diff → dry-run early return → confirm → apply).
8578
+ * Multi-app does not use this wrapper; it inserts a single cross-app confirm
8579
+ * between the phases instead.
8580
+ */
8581
+ async function runApplyAll(cliConfig, appName, options) {
8582
+ const { containers, customizeBasePath, seedExists, hasChanges } = await runApplyDiffPhase(cliConfig, appName);
8366
8583
  if (!hasChanges) p.log.success(seedExists ? "No changes detected. Seed data will still be upserted." : "No changes detected.");
8367
8584
  if (options.dryRun) {
8368
8585
  p.log.info("Dry run complete. No changes will be applied.");
@@ -8376,20 +8593,11 @@ async function runApplyAll(cliConfig, appName, options) {
8376
8593
  return { ok: true };
8377
8594
  }
8378
8595
  }
8379
- const as = p.spinner();
8380
- as.start(`Applying all domains for ${appName}...`);
8381
- const output = await applyAllForApp({
8596
+ return runApplyExecutePhase({
8382
8597
  containers,
8383
- customizeBasePath
8598
+ customizeBasePath,
8599
+ appName
8384
8600
  });
8385
- const applyFailCount = output.phases.flatMap((pr) => pr.results).filter((r) => !r.success && r.skipped !== "not-found").length;
8386
- as.stop(`Apply complete.${applyFailCount > 0 ? ` (${applyFailCount} failed)` : ""}`);
8387
- printApplyAllResults(output);
8388
- if (output.phases.flatMap((pr) => pr.results).some((r) => !r.success && r.skipped !== "not-found") || output.deployError) return {
8389
- ok: false,
8390
- error: output.deployError ?? new SystemError(SystemErrorCode.ExecutionError, `Apply failed for ${appName}.`)
8391
- };
8392
- return { ok: true };
8393
8601
  }
8394
8602
  var apply_default$10 = define({
8395
8603
  name: "apply",
@@ -8412,12 +8620,42 @@ var apply_default$10 = define({
8412
8620
  })).ok) process.exitCode = 1;
8413
8621
  },
8414
8622
  multiApp: async (plan, projectConfig) => {
8415
- await runMultiAppWithFailCheck(plan, async (app) => {
8623
+ const appDiffResults = [];
8624
+ for (const app of plan.orderedApps) {
8416
8625
  const cliConfig = resolveAppCliConfig(app, projectConfig, values);
8417
8626
  printAppHeader(app.name, app.appId);
8418
- return runApplyAll(cliConfig, app.name, {
8419
- skipConfirm,
8420
- dryRun
8627
+ const diff = await runApplyDiffPhase(cliConfig, app.name);
8628
+ appDiffResults.push({
8629
+ app,
8630
+ ...diff
8631
+ });
8632
+ }
8633
+ const hasAnyChanges = appDiffResults.some((a) => a.hasChanges || a.seedExists);
8634
+ if (!hasAnyChanges) p.log.success("No changes detected in any app.");
8635
+ if (dryRun) {
8636
+ p.log.info("Dry run complete. No changes will be applied.");
8637
+ if (appDiffResults.some((a) => !a.hasChanges && a.seedExists)) p.log.info("Note: Seed data would still be upserted when running without --dry-run.");
8638
+ return;
8639
+ }
8640
+ if (!hasAnyChanges) return;
8641
+ if (!skipConfirm) {
8642
+ const shouldContinue = await p.confirm({ message: "Apply these changes to all apps?" });
8643
+ if (p.isCancel(shouldContinue) || !shouldContinue) {
8644
+ p.cancel("Apply cancelled.");
8645
+ return;
8646
+ }
8647
+ }
8648
+ await runMultiAppWithFailCheck(plan, async (app) => {
8649
+ const entry = appDiffResults.find((a) => a.app.name === app.name);
8650
+ if (!entry) throw new SystemError(SystemErrorCode.InternalServerError, `App diff result not found for "${app.name}"`);
8651
+ if (!entry.hasChanges && !entry.seedExists) {
8652
+ p.log.info("No changes. Skipping.");
8653
+ return { ok: true };
8654
+ }
8655
+ return runApplyExecutePhase({
8656
+ containers: entry.containers,
8657
+ customizeBasePath: entry.customizeBasePath,
8658
+ appName: app.name
8421
8659
  });
8422
8660
  });
8423
8661
  }
@@ -8444,7 +8682,8 @@ function createCliCaptureContainers(input) {
8444
8682
  containers: {
8445
8683
  schema: createCliContainer({
8446
8684
  ...base,
8447
- schemaFilePath: paths.schema
8685
+ schemaFilePath: paths.schema,
8686
+ stateSchemaFilePath: buildStateFilePath(input.appName, input.baseDir)
8448
8687
  }),
8449
8688
  seed: createSeedCliContainer({
8450
8689
  ...base,
@@ -9726,7 +9965,8 @@ function createCliDiffAllContainers(input) {
9726
9965
  containers: {
9727
9966
  schema: createCliContainer({
9728
9967
  ...base,
9729
- schemaFilePath: paths.schema
9968
+ schemaFilePath: paths.schema,
9969
+ stateSchemaFilePath: buildStateFilePath(input.appName, input.baseDir)
9730
9970
  }),
9731
9971
  customization: createCustomizationCliContainer({
9732
9972
  ...base,
@@ -10596,19 +10836,342 @@ var capture_default$3 = define({
10596
10836
  }
10597
10837
  });
10598
10838
  //#endregion
10839
+ //#region src/core/domain/formSchema/services/threeWayMerge.ts
10840
+ /**
10841
+ * Normalizes a field map so that base/local/remote share the same population
10842
+ * before 3-way classification (ADR-007).
10843
+ *
10844
+ * - Subtable inner fields are removed from the top level: a subtable is treated
10845
+ * as a single entity (its inner Map is compared whole by `isFieldEqual`).
10846
+ * - GROUP definitions are removed from the field channel: GROUP structural
10847
+ * changes surface via the layout channel.
10848
+ *
10849
+ * `getFields()` (remote) already excludes system fields and flattens subtable
10850
+ * inner fields to the top level; parsed local/base schemas include GROUP and
10851
+ * subtable inner definitions. Running all three through this normalization
10852
+ * aligns their populations so classification compares like-with-like.
10853
+ */
10854
+ function normalizeForThreeWay(fields) {
10855
+ const innerCodes = collectSubtableInnerFieldCodes(fields);
10856
+ const normalized = /* @__PURE__ */ new Map();
10857
+ for (const [code, def] of fields) {
10858
+ if (def.type === "GROUP") continue;
10859
+ if (innerCodes.has(code)) continue;
10860
+ normalized.set(code, def);
10861
+ }
10862
+ return normalized;
10863
+ }
10864
+ /**
10865
+ * Computes a 3-way merge of two form schemas against a common base.
10866
+ *
10867
+ * Fields are classified per-entity over the normalized field channel; layout
10868
+ * is coarse-grained (single conflict flag, ADR-003). GROUP/subtable-inner are
10869
+ * excluded from the field channel and reconstructed from the chosen layout in
10870
+ * {@link resolveMerge}.
10871
+ */
10872
+ function computeThreeWayMerge(base, local, remote) {
10873
+ const fieldResult = classifyThreeWay(normalizeForThreeWay(base.fields), normalizeForThreeWay(local.fields), normalizeForThreeWay(remote.fields), isFieldEqual);
10874
+ const enrichedBaseLayout = enrichLayoutWithFields(base.layout, base.fields);
10875
+ const enrichedLocalLayout = enrichLayoutWithFields(local.layout, local.fields);
10876
+ const enrichedRemoteLayout = enrichLayoutWithFields(remote.layout, remote.fields);
10877
+ const layoutLocalChanged = !isLayoutEqual(enrichedBaseLayout, enrichedLocalLayout);
10878
+ const layoutRemoteChanged = !isLayoutEqual(enrichedBaseLayout, enrichedRemoteLayout);
10879
+ let layoutConflict = false;
10880
+ let mergedLayout;
10881
+ let layoutAutoSide;
10882
+ if (layoutLocalChanged && layoutRemoteChanged) if (isLayoutEqual(enrichedLocalLayout, enrichedRemoteLayout)) {
10883
+ mergedLayout = enrichedLocalLayout;
10884
+ layoutAutoSide = "local";
10885
+ } else layoutConflict = true;
10886
+ else if (layoutLocalChanged) {
10887
+ mergedLayout = enrichedLocalLayout;
10888
+ layoutAutoSide = "local";
10889
+ } else if (layoutRemoteChanged) {
10890
+ mergedLayout = enrichedRemoteLayout;
10891
+ layoutAutoSide = "remote";
10892
+ } else {
10893
+ mergedLayout = enrichedBaseLayout;
10894
+ layoutAutoSide = "base";
10895
+ }
10896
+ return {
10897
+ fieldEntries: fieldResult.entries,
10898
+ fieldConflicts: fieldResult.conflicts,
10899
+ layoutConflict,
10900
+ baseLayout: enrichedBaseLayout,
10901
+ localLayout: enrichedLocalLayout,
10902
+ remoteLayout: enrichedRemoteLayout,
10903
+ ...mergedLayout !== void 0 ? { mergedLayout } : {},
10904
+ ...layoutAutoSide !== void 0 ? { layoutAutoSide } : {},
10905
+ hasConflict: fieldResult.hasConflict || layoutConflict,
10906
+ baseFields: base.fields,
10907
+ localFields: local.fields,
10908
+ remoteFields: remote.fields
10909
+ };
10910
+ }
10911
+ /**
10912
+ * Reconstructs the merged {@link Schema} after conflicts have been resolved.
10913
+ *
10914
+ * The chosen layout (local/remote on conflict, otherwise the auto-merged
10915
+ * layout) determines the structure. GROUP/subtable-inner definitions that were
10916
+ * excluded from the field channel are reconstructed from the field map that
10917
+ * corresponds to the chosen layout, so the result is internally consistent.
10918
+ *
10919
+ * @throws when `resolution` does not cover every field conflict, or when the
10920
+ * layout resolution is inconsistent with `layoutConflict`.
10921
+ */
10922
+ function resolveMerge(merge, resolution) {
10923
+ assertResolutionCovers(merge, resolution);
10924
+ const layoutSide = merge.layoutConflict ? resolution.layout : layoutAutoSide(merge);
10925
+ const chosenLayout = layoutSide === "local" ? merge.localLayout : merge.remoteLayout;
10926
+ const chosenSideFields = layoutSide === "local" ? merge.localFields : merge.remoteFields;
10927
+ const mergedFields = new Map(chosenSideFields);
10928
+ for (const entry of merge.fieldEntries) overlayFieldEntry(mergedFields, entry, resolveFieldEntry(entry, resolution));
10929
+ assertNoOrphanFields(mergedFields, chosenLayout);
10930
+ return Schema.create(mergedFields, chosenLayout);
10931
+ }
10932
+ /**
10933
+ * Rejects merge resolutions whose field channel and layout channel disagree on
10934
+ * which fields exist. The two channels are resolved independently
10935
+ * (`resolveFieldEntry` vs `layoutSide`), so the merged field map and the chosen
10936
+ * layout can diverge in two mirror-image ways, both of which corrupt the
10937
+ * layout-driven write-back (`SchemaSerializer.serialize` walks the layout, not
10938
+ * the field map):
10939
+ *
10940
+ * 1. **Orphan field** — a field in `mergedFields` but absent from the chosen
10941
+ * layout. A field added on one side (auto-merged into `mergedFields`) is
10942
+ * placed only in that side's layout; choosing the conflicting layout from
10943
+ * the other side leaves the field unplaced, so it is silently dropped on
10944
+ * write-back (ADR-016).
10945
+ * 2. **Resurrected field** — a field placed in the chosen layout but absent
10946
+ * from `mergedFields`. A field deleted on one side (field channel resolves
10947
+ * to `undefined`, removed from `mergedFields`) still appears in the other
10948
+ * side's layout; choosing that layout makes serialization re-emit the
10949
+ * deleted field's full definition from the layout element, silently undoing
10950
+ * the deletion (ADR-017).
10951
+ *
10952
+ * Detecting both turns a silent correctness loss into an explicit, actionable
10953
+ * error: the chosen resolution combination is internally inconsistent and the
10954
+ * user must pick the layout side that matches the field-channel decisions.
10955
+ */
10956
+ function assertNoOrphanFields(mergedFields, layout) {
10957
+ const { placed, groupCodes } = collectLayoutFieldCodes(layout);
10958
+ const orphans = [];
10959
+ for (const code of mergedFields.keys()) if (!placed.has(code)) orphans.push(code);
10960
+ if (orphans.length > 0) throw new BusinessRuleError(FormSchemaErrorCode.FsOrphanMergedField, `Merge resolution leaves field(s) not placed in the chosen layout: ${orphans.join(", ")}. The adopted field exists on a different side than the chosen layout; choose the layout side that contains the field.`);
10961
+ const resurrected = [];
10962
+ for (const code of placed) {
10963
+ if (groupCodes.has(code)) continue;
10964
+ if (!mergedFields.has(code)) resurrected.push(code);
10965
+ }
10966
+ if (resurrected.length > 0) throw new BusinessRuleError(FormSchemaErrorCode.FsOrphanMergedField, `Merge resolution keeps field(s) placed in the chosen layout that were removed from the field set: ${resurrected.join(", ")}. The field was deleted on one side but the chosen layout still contains it; choose the layout side that omits the deleted field.`);
10967
+ }
10968
+ function collectLayoutFieldCodes(layout) {
10969
+ const placed = /* @__PURE__ */ new Set();
10970
+ const groupCodes = /* @__PURE__ */ new Set();
10971
+ const collectElements = (elements) => {
10972
+ for (const element of elements) if (element.kind === "field") placed.add(element.field.code);
10973
+ };
10974
+ for (const item of layout) switch (item.type) {
10975
+ case "ROW":
10976
+ collectElements(item.fields);
10977
+ break;
10978
+ case "GROUP":
10979
+ placed.add(item.code);
10980
+ groupCodes.add(item.code);
10981
+ for (const row of item.layout) collectElements(row.fields);
10982
+ break;
10983
+ case "SUBTABLE":
10984
+ placed.add(item.code);
10985
+ collectElements(item.fields);
10986
+ break;
10987
+ case "REFERENCE_TABLE":
10988
+ placed.add(item.code);
10989
+ break;
10990
+ }
10991
+ return {
10992
+ placed,
10993
+ groupCodes
10994
+ };
10995
+ }
10996
+ function assertResolutionCovers(merge, resolution) {
10997
+ for (const conflict of merge.fieldConflicts) if (!resolution.fields.has(conflict.key)) throw new BusinessRuleError(FormSchemaErrorCode.FsInvalidMergeResolution, `Merge resolution missing field conflict: ${conflict.key}`);
10998
+ if (merge.layoutConflict && resolution.layout !== "local" && resolution.layout !== "remote") throw new BusinessRuleError(FormSchemaErrorCode.FsInvalidMergeResolution, "Merge resolution must choose a layout side for the layout conflict");
10999
+ }
11000
+ function dropSubtableInner(fields, code) {
11001
+ const existing = fields.get(code);
11002
+ if (existing?.type === "SUBTABLE") for (const innerCode of existing.properties.fields.keys()) fields.delete(innerCode);
11003
+ }
11004
+ function overlayFieldEntry(mergedFields, entry, resolved) {
11005
+ dropSubtableInner(mergedFields, entry.key);
11006
+ if (resolved === void 0) {
11007
+ mergedFields.delete(entry.key);
11008
+ return;
11009
+ }
11010
+ mergedFields.set(entry.key, resolved);
11011
+ if (resolved.type === "SUBTABLE") for (const [innerCode, innerDef] of resolved.properties.fields) mergedFields.set(innerCode, innerDef);
11012
+ }
11013
+ function layoutAutoSide(merge) {
11014
+ return merge.layoutAutoSide === "remote" ? "remote" : "local";
11015
+ }
11016
+ function resolveFieldEntry(entry, resolution) {
11017
+ switch (entry.change.kind) {
11018
+ case "unchanged":
11019
+ case "localOnly":
11020
+ case "bothSame": return entry.merged;
11021
+ case "remoteOnly": return entry.merged;
11022
+ case "conflict": return resolution.fields.get(entry.key) === "remote" ? entry.remote : entry.local;
11023
+ }
11024
+ }
11025
+ //#endregion
11026
+ //#region src/core/domain/formSchema/services/schemaStateParser.ts
11027
+ /**
11028
+ * Parses pre-parsed (codec-decoded) data into a {@link SchemaState}.
11029
+ *
11030
+ * The top-level `revision` is extracted and the remainder (the captured
11031
+ * `layout`) is parsed via {@link SchemaParser}. This is the inverse of
11032
+ * {@link SchemaStateSerializer}. The domain layer does not depend on YAML;
11033
+ * the application layer handles codec decoding before calling this.
11034
+ */
11035
+ const SchemaStateParser = { parse: (parsed) => {
11036
+ if (!isRecord(parsed)) throw new BusinessRuleError(FormSchemaErrorCode.FsInvalidStateStructure, "Schema state must be an object");
11037
+ const { revision, ...rest } = parsed;
11038
+ if (typeof revision !== "string" || revision.length === 0) throw new BusinessRuleError(FormSchemaErrorCode.FsInvalidStateStructure, "Schema state must have a non-empty \"revision\" string");
11039
+ return {
11040
+ revision,
11041
+ schema: SchemaParser.parse(rest)
11042
+ };
11043
+ } };
11044
+ //#endregion
11045
+ //#region src/core/domain/formSchema/services/schemaStateSerializer.ts
11046
+ /**
11047
+ * Serializes a {@link SchemaState} to a plain object suitable for YAML
11048
+ * stringification (via the codec port in the application layer).
11049
+ *
11050
+ * The schema portion is serialized through the exact same path as `capture`
11051
+ * (`enrichLayoutWithFields` -> `SchemaSerializer.serialize`) so that the state
11052
+ * snapshot is round-trip compatible with the captured schema (ADR-007). The
11053
+ * top-level `revision` is added alongside the captured `layout`.
11054
+ */
11055
+ const SchemaStateSerializer = { serialize: (state) => {
11056
+ const enrichedLayout = enrichLayoutWithFields(state.schema.layout, state.schema.fields);
11057
+ return {
11058
+ revision: state.revision,
11059
+ ...SchemaSerializer.serialize(enrichedLayout, state.schema.fields)
11060
+ };
11061
+ } };
11062
+ //#endregion
11063
+ //#region src/core/application/formSchema/schemaStateIo.ts
11064
+ /** Loads and parses the schema state, or returns undefined when none exists. */
11065
+ async function loadState(storage, codec) {
11066
+ const result = await storage.get();
11067
+ if (!result.exists) return;
11068
+ const parsed = parseConfigText(codec, result.content, "Schema state");
11069
+ return wrapBusinessRuleError(() => SchemaStateParser.parse(parsed));
11070
+ }
11071
+ /** Serializes and persists the schema state via the codec port. */
11072
+ async function saveState(storage, codec, revision, schema) {
11073
+ const text = stringifyConfig(codec, SchemaStateSerializer.serialize({
11074
+ revision,
11075
+ schema
11076
+ }));
11077
+ await storage.update(text);
11078
+ }
11079
+ //#endregion
11080
+ //#region src/core/application/formSchema/loadThreeWayInputs.ts
11081
+ /**
11082
+ * Loads the three inputs of a 3-way schema sync: the base snapshot (state), the
11083
+ * local YAML schema, and the remote schema, plus the current remote revision.
11084
+ *
11085
+ * The remote schema is reconstructed via the same enrichment path as `capture`
11086
+ * so it compares like-with-like against base/local after normalization
11087
+ * (ADR-007). `getRevision` is read for the drift signal / expected revision but
11088
+ * never short-circuits snapshot fetching (ADR-004).
11089
+ */
11090
+ async function loadThreeWayInputs(container) {
11091
+ const [state, localResult, remoteFields, remoteLayout, remoteRevision] = await Promise.all([
11092
+ loadState(container.schemaStateStorage, container.configCodec),
11093
+ container.schemaStorage.get(),
11094
+ container.formConfigurator.getFields(),
11095
+ container.formConfigurator.getLayout(),
11096
+ container.formConfigurator.getRevision()
11097
+ ]);
11098
+ const local = localResult.exists ? parseSchemaText(container.configCodec, localResult.content) : void 0;
11099
+ const enrichedRemoteLayout = enrichLayoutWithFields(remoteLayout, remoteFields);
11100
+ return {
11101
+ state,
11102
+ local,
11103
+ remote: Schema.create(remoteFields, enrichedRemoteLayout),
11104
+ remoteRevision
11105
+ };
11106
+ }
11107
+ //#endregion
11108
+ //#region src/core/application/formSchema/detectThreeWayDiff.ts
11109
+ function toEntry(entry, kind) {
11110
+ const label = entry.local?.label ?? entry.remote?.label ?? entry.base?.label ?? "";
11111
+ return {
11112
+ fieldCode: entry.key,
11113
+ fieldLabel: label,
11114
+ kind
11115
+ };
11116
+ }
11117
+ /**
11118
+ * Detects differences with 3-way awareness (AC-10, AC-11).
11119
+ *
11120
+ * When a state (base snapshot) exists, classifies changes into local-only,
11121
+ * remote drift, and conflicts. When no state exists, falls back to the existing
11122
+ * 2-way `detectDiff`.
11123
+ */
11124
+ async function detectThreeWayDiff({ container }) {
11125
+ const { state, local, remote } = await loadThreeWayInputs(container);
11126
+ if (state === void 0 || local === void 0) return {
11127
+ mode: "two-way",
11128
+ diff: await detectDiff({ container })
11129
+ };
11130
+ const merge = computeThreeWayMerge(state.schema, local, remote);
11131
+ const localChanges = [];
11132
+ const remoteDrift = [];
11133
+ const conflicts = [];
11134
+ for (const entry of merge.fieldEntries) switch (entry.change.kind) {
11135
+ case "localOnly":
11136
+ localChanges.push(toEntry(entry, "localOnly"));
11137
+ break;
11138
+ case "remoteOnly":
11139
+ remoteDrift.push(toEntry(entry, "remoteOnly"));
11140
+ break;
11141
+ case "conflict":
11142
+ conflicts.push(toEntry(entry, "conflict"));
11143
+ break;
11144
+ default: break;
11145
+ }
11146
+ const enrichedBaseLayout = enrichLayoutWithFields(state.schema.layout, state.schema.fields);
11147
+ const layoutLocalChanged = !isLayoutEqual(enrichedBaseLayout, merge.localLayout);
11148
+ const layoutRemoteChanged = !isLayoutEqual(enrichedBaseLayout, merge.remoteLayout);
11149
+ const isEmpty = localChanges.length === 0 && remoteDrift.length === 0 && conflicts.length === 0 && !layoutLocalChanged && !layoutRemoteChanged;
11150
+ return {
11151
+ mode: "three-way",
11152
+ localChanges,
11153
+ remoteDrift,
11154
+ conflicts,
11155
+ layoutLocalChanged,
11156
+ layoutRemoteChanged,
11157
+ layoutConflict: merge.layoutConflict,
11158
+ isEmpty
11159
+ };
11160
+ }
11161
+ //#endregion
10599
11162
  //#region src/cli/commands/schema/diff.ts
10600
11163
  async function runDiff(container) {
10601
11164
  const s = p.spinner();
10602
11165
  s.start("Comparing schema...");
10603
11166
  let result;
10604
11167
  try {
10605
- result = await detectDiff({ container });
11168
+ result = await detectThreeWayDiff({ container });
10606
11169
  } catch (error) {
10607
11170
  s.stop("Comparison failed.");
10608
11171
  throw error;
10609
11172
  }
10610
11173
  s.stop("Comparison complete.");
10611
- printDiffResult(result);
11174
+ printThreeWayDiffResult(result);
10612
11175
  }
10613
11176
  var diff_default$2 = define({
10614
11177
  name: "diff",
@@ -11052,6 +11615,317 @@ var override_default = define({
11052
11615
  }
11053
11616
  });
11054
11617
  //#endregion
11618
+ //#region src/core/application/formSchema/pullSchema.ts
11619
+ function serializeSchema(container, schema) {
11620
+ const enrichedLayout = enrichLayoutWithFields(schema.layout, schema.fields);
11621
+ return stringifyConfig(container.configCodec, SchemaSerializer.serialize(enrichedLayout, schema.fields));
11622
+ }
11623
+ /**
11624
+ * First stage of `schema pull` (AC-4, AC-5, AC-6, AC-11).
11625
+ *
11626
+ * - `force`: returns the remote snapshot for local overwrite (capture-equiv).
11627
+ * - first run (no state): returns remote for one-way overwrite.
11628
+ * - otherwise: computes the 3-way merge and returns it for conflict resolution
11629
+ * by the CLI. The local YAML / state are NOT written here — that happens in
11630
+ * {@link applyPulledMerge} after resolution, so an aborted resolution leaves
11631
+ * local and state untouched (AC-15).
11632
+ *
11633
+ * This stage never writes to the remote (pull is read-only against kintone).
11634
+ */
11635
+ async function pullSchema({ container, input }) {
11636
+ const { state, local, remote, remoteRevision } = await loadThreeWayInputs(container);
11637
+ if (input.force) {
11638
+ const schemaText = serializeSchema(container, remote);
11639
+ await container.schemaStorage.update(schemaText);
11640
+ await saveState(container.schemaStateStorage, container.configCodec, remoteRevision, remote);
11641
+ return {
11642
+ mode: "force",
11643
+ schemaText
11644
+ };
11645
+ }
11646
+ if (state === void 0 || local === void 0) {
11647
+ const schemaText = serializeSchema(container, remote);
11648
+ await container.schemaStorage.update(schemaText);
11649
+ await saveState(container.schemaStateStorage, container.configCodec, remoteRevision, remote);
11650
+ return {
11651
+ mode: "firstTime",
11652
+ schemaText
11653
+ };
11654
+ }
11655
+ return {
11656
+ mode: "merged",
11657
+ merge: computeThreeWayMerge(state.schema, local, remote),
11658
+ remoteRevision,
11659
+ remoteSchema: remote
11660
+ };
11661
+ }
11662
+ /**
11663
+ * Second stage of `schema pull`: applies a resolved 3-way merge.
11664
+ *
11665
+ * Writes the merged schema to the local YAML and updates the state to the
11666
+ * remote snapshot/revision. Called only after the CLI has fully resolved all
11667
+ * conflicts; if the user aborts resolution this is never invoked, so local and
11668
+ * state remain unchanged (AC-15).
11669
+ */
11670
+ async function applyPulledMerge({ container, input }) {
11671
+ const merged = wrapBusinessRuleError(() => resolveMerge(input.merge, input.resolution));
11672
+ assertSchemaValid(merged);
11673
+ const schemaText = serializeSchema(container, merged);
11674
+ await container.schemaStorage.update(schemaText);
11675
+ await saveState(container.schemaStateStorage, container.configCodec, input.remoteRevision, input.remoteSchema);
11676
+ return { schemaText };
11677
+ }
11678
+ //#endregion
11679
+ //#region src/cli/commands/schema/pull.ts
11680
+ async function resolveConflicts(merge, options) {
11681
+ const fields = /* @__PURE__ */ new Map();
11682
+ for (const conflict of merge.fieldConflicts) {
11683
+ let choice;
11684
+ if (options.ours) choice = "local";
11685
+ else if (options.theirs) choice = "remote";
11686
+ else {
11687
+ const selected = await p.select({
11688
+ message: `Conflict on field "${conflict.key}". Keep which side?`,
11689
+ options: [{
11690
+ value: "local",
11691
+ label: "local (ours)"
11692
+ }, {
11693
+ value: "remote",
11694
+ label: "remote (theirs)"
11695
+ }]
11696
+ });
11697
+ if (p.isCancel(selected)) return;
11698
+ choice = selected;
11699
+ }
11700
+ fields.set(conflict.key, choice);
11701
+ }
11702
+ let layout = "noConflict";
11703
+ if (merge.layoutConflict) if (options.ours) layout = "local";
11704
+ else if (options.theirs) layout = "remote";
11705
+ else {
11706
+ const selected = await p.select({
11707
+ message: "Conflict on layout. Keep which side?",
11708
+ options: [{
11709
+ value: "local",
11710
+ label: "local (ours)"
11711
+ }, {
11712
+ value: "remote",
11713
+ label: "remote (theirs)"
11714
+ }]
11715
+ });
11716
+ if (p.isCancel(selected)) return;
11717
+ layout = selected;
11718
+ }
11719
+ return {
11720
+ fields,
11721
+ layout
11722
+ };
11723
+ }
11724
+ async function runPull(container, schemaFilePath, options) {
11725
+ const s = p.spinner();
11726
+ s.start("Pulling schema from kintone...");
11727
+ const result = await pullSchema({
11728
+ container,
11729
+ input: { force: options.force }
11730
+ });
11731
+ s.stop("Schema pulled.");
11732
+ if (result.mode === "force") {
11733
+ p.log.success(`Local schema overwritten from remote: ${pc.cyan(schemaFilePath)}`);
11734
+ return;
11735
+ }
11736
+ if (result.mode === "firstTime") {
11737
+ p.log.warn("No base snapshot found. Initialized state from remote (one-way pull).");
11738
+ p.log.success(`Local schema written: ${pc.cyan(schemaFilePath)}`);
11739
+ return;
11740
+ }
11741
+ const { merge } = result;
11742
+ if (!merge.hasConflict) {
11743
+ await applyPulledMerge({
11744
+ container,
11745
+ input: {
11746
+ merge,
11747
+ resolution: {
11748
+ fields: /* @__PURE__ */ new Map(),
11749
+ layout: "noConflict"
11750
+ },
11751
+ remoteRevision: result.remoteRevision,
11752
+ remoteSchema: result.remoteSchema
11753
+ }
11754
+ });
11755
+ p.log.success(`Local schema merged and written: ${pc.cyan(schemaFilePath)}`);
11756
+ return;
11757
+ }
11758
+ if (options.ours) p.log.info("Resolving all conflicts in favor of local (--ours).");
11759
+ else if (options.theirs) p.log.info("Resolving all conflicts in favor of remote (--theirs).");
11760
+ const resolution = await resolveConflicts(merge, options);
11761
+ if (resolution === void 0) {
11762
+ p.cancel("Pull cancelled. Local schema and state were left unchanged.");
11763
+ return;
11764
+ }
11765
+ await applyPulledMerge({
11766
+ container,
11767
+ input: {
11768
+ merge,
11769
+ resolution,
11770
+ remoteRevision: result.remoteRevision,
11771
+ remoteSchema: result.remoteSchema
11772
+ }
11773
+ });
11774
+ p.log.success(`Conflicts resolved. Local schema written: ${pc.cyan(schemaFilePath)}`);
11775
+ }
11776
+ var pull_default = define({
11777
+ name: "pull",
11778
+ description: "Pull remote form schema into the local schema file (3-way merge)",
11779
+ args: {
11780
+ ...kintoneArgs,
11781
+ ...multiAppArgs,
11782
+ force: {
11783
+ type: "boolean",
11784
+ description: "Overwrite local with remote (capture-equivalent)"
11785
+ },
11786
+ ours: {
11787
+ type: "boolean",
11788
+ description: "Resolve all conflicts in favor of local"
11789
+ },
11790
+ theirs: {
11791
+ type: "boolean",
11792
+ description: "Resolve all conflicts in favor of remote"
11793
+ }
11794
+ },
11795
+ run: async (ctx) => {
11796
+ try {
11797
+ if (ctx.values.all === true) throw new ValidationError(ValidationErrorCode.InvalidInput, "schema pull does not support --all yet. Use --app <name> or a single app.");
11798
+ const options = {
11799
+ force: ctx.values.force === true,
11800
+ ours: ctx.values.ours === true,
11801
+ theirs: ctx.values.theirs === true
11802
+ };
11803
+ if (options.ours && options.theirs) throw new ValidationError(ValidationErrorCode.InvalidInput, "--ours and --theirs cannot be used together");
11804
+ await routeMultiApp(ctx.values, {
11805
+ singleLegacy: async () => {
11806
+ const config = resolveConfig(ctx.values);
11807
+ await runPull(createCliContainer(config), config.schemaFilePath, options);
11808
+ },
11809
+ singleApp: async (app, projectConfig) => {
11810
+ const config = resolveAppCliConfig(app, projectConfig, ctx.values);
11811
+ await runPull(createCliContainer(config), config.schemaFilePath, options);
11812
+ },
11813
+ multiApp: async () => {
11814
+ throw new ValidationError(ValidationErrorCode.InvalidInput, "schema pull does not support --all yet. Use --app <name> or a single app.");
11815
+ }
11816
+ });
11817
+ } catch (error) {
11818
+ handleCliError(error);
11819
+ }
11820
+ }
11821
+ });
11822
+ //#endregion
11823
+ //#region src/core/application/formSchema/pushSchema.ts
11824
+ /** Message thrown when the remote drifted from base and `--force` was not set. */
11825
+ const PUSH_DRIFT_MESSAGE = "The remote has changed since the base snapshot. Run `schema pull` first.";
11826
+ /**
11827
+ * Applies the local schema to the remote with drift detection and optimistic
11828
+ * concurrency control (AC-7, AC-8, AC-9, AC-11, AC-14).
11829
+ *
11830
+ * - Reads the current revision (expected-revision source + drift signal) and
11831
+ * the remote snapshot (drift judged by snapshot comparison, never skipped by
11832
+ * revision — ADR-004).
11833
+ * - drift && !force → {@link ConflictError} tagged with `SchemaDrift` (drift
11834
+ * distinguished from API optimistic-lock conflicts by error code — ADR-008).
11835
+ * - otherwise applies the local schema via the shared {@link applySchemaChanges}
11836
+ * (type changes / subtable additions rejected with ValidationError — AC-13),
11837
+ * sending the observed remote revision as the expected revision (ADR-005).
11838
+ * - `--force` skips the drift check and sends no expected revision.
11839
+ * - first run (no state): applies with no revision guard and initializes state.
11840
+ *
11841
+ * Deploy is performed by the CLI (single-path `confirmAndDeploy`). State records
11842
+ * the preview revision observed after applying.
11843
+ */
11844
+ async function pushSchema({ container, input }) {
11845
+ const { state, local, remote, remoteRevision } = await loadThreeWayInputs(container);
11846
+ if (local === void 0) throw new ValidationError(ValidationErrorCode.InvalidInput, "Schema file not found");
11847
+ assertSchemaValid(local);
11848
+ const firstTime = state === void 0;
11849
+ if (!firstTime && !input.force) {
11850
+ const merge = computeThreeWayMerge(state.schema, local, remote);
11851
+ const hasFieldDrift = merge.fieldEntries.some((e) => e.change.kind === "remoteOnly" || e.change.kind === "conflict");
11852
+ const layoutDrift = !isLayoutEqual(enrichLayoutWithFields(state.schema.layout, state.schema.fields), merge.remoteLayout);
11853
+ if (hasFieldDrift || layoutDrift) throw new ConflictError(ConflictErrorCode.SchemaDrift, PUSH_DRIFT_MESSAGE);
11854
+ }
11855
+ await applySchemaChanges(local, {
11856
+ container,
11857
+ expectedRevision: input.force || firstTime ? SKIP_REVISION_CHECK : remoteRevision
11858
+ });
11859
+ const newRevision = await container.formConfigurator.getRevision();
11860
+ await saveState(container.schemaStateStorage, container.configCodec, newRevision, local);
11861
+ return {
11862
+ mode: firstTime ? "firstTime" : "push",
11863
+ revision: newRevision
11864
+ };
11865
+ }
11866
+ //#endregion
11867
+ //#region src/cli/commands/schema/push.ts
11868
+ const PUSH_TOCTOU_MESSAGE = "The remote changed while applying. Run `schema pull` and retry.";
11869
+ async function runPush(container, force, skipConfirm) {
11870
+ if (!skipConfirm) {
11871
+ const shouldContinue = await p.confirm({ message: force ? "Force-push local schema to kintone (overwrite remote)?" : "Push local schema to kintone?" });
11872
+ if (p.isCancel(shouldContinue) || !shouldContinue) {
11873
+ p.cancel("Push cancelled.");
11874
+ return;
11875
+ }
11876
+ }
11877
+ const s = p.spinner();
11878
+ s.start("Pushing schema to kintone...");
11879
+ let result;
11880
+ try {
11881
+ result = await pushSchema({
11882
+ container,
11883
+ input: { force }
11884
+ });
11885
+ } catch (error) {
11886
+ s.stop("Push failed.");
11887
+ if (isConflictError(error) && error.code !== ConflictErrorCode.SchemaDrift) throw new ConflictError(ConflictErrorCode.Conflict, PUSH_TOCTOU_MESSAGE, error);
11888
+ throw error;
11889
+ }
11890
+ s.stop("Schema pushed to preview.");
11891
+ if (result.mode === "firstTime") p.log.warn("No base snapshot found. Applied without drift guard and initialized state.");
11892
+ p.log.success("Push completed successfully.");
11893
+ await confirmAndDeploy([container], skipConfirm);
11894
+ }
11895
+ var push_default = define({
11896
+ name: "push",
11897
+ description: "Push the local schema file to kintone (with drift detection)",
11898
+ args: {
11899
+ ...kintoneArgs,
11900
+ ...multiAppArgs,
11901
+ ...confirmArgs,
11902
+ force: {
11903
+ type: "boolean",
11904
+ description: "Skip drift detection and overwrite remote"
11905
+ }
11906
+ },
11907
+ run: async (ctx) => {
11908
+ try {
11909
+ if (ctx.values.all === true) throw new ValidationError(ValidationErrorCode.InvalidInput, "schema push does not support --all yet. Use --app <name> or a single app.");
11910
+ const force = ctx.values.force === true;
11911
+ const skipConfirm = ctx.values.yes === true;
11912
+ await routeMultiApp(ctx.values, {
11913
+ singleLegacy: async () => {
11914
+ await runPush(createCliContainer(resolveConfig(ctx.values)), force, skipConfirm);
11915
+ },
11916
+ singleApp: async (app, projectConfig) => {
11917
+ await runPush(createCliContainer(resolveAppCliConfig(app, projectConfig, ctx.values)), force, skipConfirm);
11918
+ },
11919
+ multiApp: async () => {
11920
+ throw new ValidationError(ValidationErrorCode.InvalidInput, "schema push does not support --all yet. Use --app <name> or a single app.");
11921
+ }
11922
+ });
11923
+ } catch (error) {
11924
+ handleCliError(error);
11925
+ }
11926
+ }
11927
+ });
11928
+ //#endregion
11055
11929
  //#region src/core/application/container/validateCli.ts
11056
11930
  function createValidateCliContainer(config) {
11057
11931
  return {
@@ -11169,7 +12043,9 @@ var schema_default = define({
11169
12043
  }
11170
12044
  }
11171
12045
  }),
11172
- dump: dump_default
12046
+ dump: dump_default,
12047
+ pull: pull_default,
12048
+ push: push_default
11173
12049
  },
11174
12050
  run: () => {}
11175
12051
  });