kintone-migrator 0.31.5 → 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 +976 -100
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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 = {
|
|
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
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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:
|
|
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) {
|
|
@@ -2682,7 +2831,7 @@ async function runMultiAppWithFailCheck(plan, executor, successMessage) {
|
|
|
2682
2831
|
printMultiAppResult(multiResult);
|
|
2683
2832
|
if (multiResult.hasFailure) {
|
|
2684
2833
|
const succeededCount = multiResult.results.filter((r) => r.status === "succeeded").length;
|
|
2685
|
-
if (succeededCount > 0) p.log.warn(`${succeededCount} app(s)
|
|
2834
|
+
if (succeededCount > 0) p.log.warn(`${succeededCount} app(s) completed before execution stopped. Check their status in kintone.`);
|
|
2686
2835
|
throw new SystemError(SystemErrorCode.ExecutionError, "Execution stopped due to failure.");
|
|
2687
2836
|
}
|
|
2688
2837
|
if (successMessage) p.log.success(successMessage);
|
|
@@ -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
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8419
|
-
|
|
8420
|
-
|
|
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
|
|
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
|
-
|
|
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
|
});
|