@frockbot/configuration-core 0.2.2 → 0.2.4
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/package.json +3 -3
- package/src/index.test.ts +131 -0
- package/src/index.ts +185 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/configuration-core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@frockbot/connection-core": "0.2.
|
|
16
|
-
"@frockbot/kernel-composition": "0.2.
|
|
15
|
+
"@frockbot/connection-core": "0.2.4",
|
|
16
|
+
"@frockbot/kernel-composition": "0.2.4"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@types/bun": "1.3.6",
|
package/src/index.test.ts
CHANGED
|
@@ -98,6 +98,137 @@ describe("stored configuration migrations", () => {
|
|
|
98
98
|
);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
test("retires Catalog-orphaned platform state and disables inconsistent dependents", () => {
|
|
102
|
+
const stored = {
|
|
103
|
+
schemaVersion: 1,
|
|
104
|
+
revision: 12,
|
|
105
|
+
profile: { name: "Legacy User" },
|
|
106
|
+
packages: [
|
|
107
|
+
{
|
|
108
|
+
packageId: "retired-provider",
|
|
109
|
+
version: "0.0.1",
|
|
110
|
+
state: "installed" as const,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
packageId: "current-provider",
|
|
114
|
+
version: "0.0.1",
|
|
115
|
+
state: "installed" as const,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
connections: [
|
|
119
|
+
{
|
|
120
|
+
connectionId: "retired-connection",
|
|
121
|
+
packageId: "retired-provider",
|
|
122
|
+
connectionTypeId: "retired-account",
|
|
123
|
+
displayName: "Retired",
|
|
124
|
+
state: "ready" as const,
|
|
125
|
+
safeMetadata: {},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
connectionId: "current-connection",
|
|
129
|
+
packageId: "current-provider",
|
|
130
|
+
connectionTypeId: "current-account",
|
|
131
|
+
displayName: "Current",
|
|
132
|
+
state: "ready" as const,
|
|
133
|
+
safeMetadata: {},
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
platformModel: {
|
|
137
|
+
connectionId: "retired-connection",
|
|
138
|
+
providerModelId: "retired-model",
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
const migrated = decodeUserSettingsViewV1(
|
|
142
|
+
migrateStoredUserSettingsV1(stored, [
|
|
143
|
+
{
|
|
144
|
+
packageId: "current-provider",
|
|
145
|
+
version: "0.0.1",
|
|
146
|
+
dependencies: { "model-choice": ">=0.0.1" },
|
|
147
|
+
},
|
|
148
|
+
{ packageId: "model-choice", version: "0.0.1" },
|
|
149
|
+
]),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(migrated.packages).toEqual([
|
|
153
|
+
{
|
|
154
|
+
packageId: "current-provider",
|
|
155
|
+
version: "0.0.1",
|
|
156
|
+
state: "disabled",
|
|
157
|
+
},
|
|
158
|
+
]);
|
|
159
|
+
expect(migrated.connections).toEqual([stored.connections[1]]);
|
|
160
|
+
expect(migrated.platformModel).toBeUndefined();
|
|
161
|
+
expect(stored.packages).toHaveLength(2);
|
|
162
|
+
expect(stored.connections).toHaveLength(2);
|
|
163
|
+
expect(stored.platformModel).toBeDefined();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("keeps version mismatches and malformed platform bindings visible", () => {
|
|
167
|
+
const versionMismatch = {
|
|
168
|
+
schemaVersion: 1,
|
|
169
|
+
revision: 0,
|
|
170
|
+
profile: { name: "Existing User" },
|
|
171
|
+
packages: [
|
|
172
|
+
{
|
|
173
|
+
packageId: "provider",
|
|
174
|
+
version: "0.0.1",
|
|
175
|
+
state: "installed" as const,
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
connections: [],
|
|
179
|
+
};
|
|
180
|
+
expect(
|
|
181
|
+
migrateStoredUserSettingsV1(versionMismatch, [
|
|
182
|
+
{ packageId: "provider", version: "0.0.2" },
|
|
183
|
+
]),
|
|
184
|
+
).toBe(versionMismatch);
|
|
185
|
+
|
|
186
|
+
const remoteCatalogInstall = {
|
|
187
|
+
...versionMismatch,
|
|
188
|
+
packages: [
|
|
189
|
+
{
|
|
190
|
+
packageId: "remote-provider",
|
|
191
|
+
version: "1.2.3",
|
|
192
|
+
state: "installed" as const,
|
|
193
|
+
provenance: "catalog" as const,
|
|
194
|
+
catalogId: "remote-provider",
|
|
195
|
+
catalogGeneration: "generation-1",
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
connections: [
|
|
199
|
+
{
|
|
200
|
+
connectionId: "remote-connection",
|
|
201
|
+
packageId: "remote-provider",
|
|
202
|
+
connectionTypeId: "remote-account",
|
|
203
|
+
displayName: "Remote",
|
|
204
|
+
state: "ready" as const,
|
|
205
|
+
safeMetadata: {},
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
expect(
|
|
210
|
+
migrateStoredUserSettingsV1(remoteCatalogInstall, [
|
|
211
|
+
{ packageId: "provider", version: "0.0.2" },
|
|
212
|
+
]),
|
|
213
|
+
).toBe(remoteCatalogInstall);
|
|
214
|
+
|
|
215
|
+
const malformedPlatform = {
|
|
216
|
+
...versionMismatch,
|
|
217
|
+
platformModel: {
|
|
218
|
+
connectionId: "provider-connection",
|
|
219
|
+
providerModelId: "model",
|
|
220
|
+
unknown: true,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
expect(() =>
|
|
224
|
+
decodeUserSettingsViewV1(
|
|
225
|
+
migrateStoredUserSettingsV1(malformedPlatform, [
|
|
226
|
+
{ packageId: "provider", version: "0.0.1" },
|
|
227
|
+
]),
|
|
228
|
+
),
|
|
229
|
+
).toThrow(ConfigurationDecodeError);
|
|
230
|
+
});
|
|
231
|
+
|
|
101
232
|
test("keeps unknown User, Package, and Connection fields strict", () => {
|
|
102
233
|
const current = {
|
|
103
234
|
schemaVersion: 1,
|
package/src/index.ts
CHANGED
|
@@ -1861,6 +1861,177 @@ const PRE_ACCOUNT_WIDE_CONNECTION_METADATA_FIELDS_V1 = [
|
|
|
1861
1861
|
"dependentAssignments",
|
|
1862
1862
|
] as const;
|
|
1863
1863
|
|
|
1864
|
+
/**
|
|
1865
|
+
* The immutable Package facts the stored User-settings migration needs. The
|
|
1866
|
+
* migration deliberately knows no Package names: absence from this Catalog is
|
|
1867
|
+
* what retires an installation, and these dependency edges are what identify
|
|
1868
|
+
* an enablement state an older writer could not have represented safely.
|
|
1869
|
+
*/
|
|
1870
|
+
export interface StoredUserSettingsPackageV1 {
|
|
1871
|
+
packageId: string;
|
|
1872
|
+
version: string;
|
|
1873
|
+
dependencies?: Readonly<Record<string, string>>;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
function migrateCatalogRelativeUserSettingsV1(
|
|
1877
|
+
settings: Record<string, unknown>,
|
|
1878
|
+
packages: readonly StoredUserSettingsPackageV1[],
|
|
1879
|
+
): Record<string, unknown> {
|
|
1880
|
+
const availableVersions = new Map(
|
|
1881
|
+
packages.map((pkg) => [`${pkg.packageId}\u0000${pkg.version}`, pkg]),
|
|
1882
|
+
);
|
|
1883
|
+
const availablePackageIds = new Set(packages.map((pkg) => pkg.packageId));
|
|
1884
|
+
const storedPackages = storedDataValueV1(settings, "packages");
|
|
1885
|
+
const storedConnections = storedDataValueV1(settings, "connections");
|
|
1886
|
+
if (!Array.isArray(storedPackages) || !Array.isArray(storedConnections)) {
|
|
1887
|
+
return settings;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
let changed = false;
|
|
1891
|
+
const retiredFirstPartyPackageIds = new Set<string>();
|
|
1892
|
+
let installations = storedPackages.filter((storedInstallation) => {
|
|
1893
|
+
const installation = storedPlainRecordV1(storedInstallation);
|
|
1894
|
+
if (!installation) return true;
|
|
1895
|
+
const packageId = storedDataValueV1(installation, "packageId");
|
|
1896
|
+
const version = storedDataValueV1(installation, "version");
|
|
1897
|
+
const provenance = storedDataValueV1(installation, "provenance");
|
|
1898
|
+
if (typeof packageId !== "string" || typeof version !== "string") {
|
|
1899
|
+
return true;
|
|
1900
|
+
}
|
|
1901
|
+
// The facts supplied here are the running application's first-party
|
|
1902
|
+
// Catalog. Remote Catalog installations have a separate pinned authority
|
|
1903
|
+
// and must never be inferred retired from absence here.
|
|
1904
|
+
if (provenance === "catalog") return true;
|
|
1905
|
+
// A different immutable version remains a visible, repairable Catalog
|
|
1906
|
+
// mismatch. Only an id absent from the Catalog proves that the Package was
|
|
1907
|
+
// retired and that its row is now orphaned durable state.
|
|
1908
|
+
const retained = availablePackageIds.has(packageId);
|
|
1909
|
+
if (!retained) retiredFirstPartyPackageIds.add(packageId);
|
|
1910
|
+
changed ||= !retained;
|
|
1911
|
+
return retained;
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
// Disable inconsistent installed dependents to the least-authority state.
|
|
1915
|
+
// Iterate to a fixed point so A -> B -> missing C leaves both A and B off.
|
|
1916
|
+
for (;;) {
|
|
1917
|
+
const enabledIds = new Set(
|
|
1918
|
+
installations.flatMap((storedInstallation) => {
|
|
1919
|
+
const installation = storedPlainRecordV1(storedInstallation);
|
|
1920
|
+
const packageId = installation
|
|
1921
|
+
? storedDataValueV1(installation, "packageId")
|
|
1922
|
+
: undefined;
|
|
1923
|
+
return typeof packageId === "string" &&
|
|
1924
|
+
storedDataValueV1(installation!, "state") === "installed"
|
|
1925
|
+
? [packageId]
|
|
1926
|
+
: [];
|
|
1927
|
+
}),
|
|
1928
|
+
);
|
|
1929
|
+
let disabledInPass = false;
|
|
1930
|
+
installations = installations.map((storedInstallation) => {
|
|
1931
|
+
const installation = storedPlainRecordV1(storedInstallation);
|
|
1932
|
+
if (
|
|
1933
|
+
!installation ||
|
|
1934
|
+
storedDataValueV1(installation, "state") !== "installed"
|
|
1935
|
+
) {
|
|
1936
|
+
return storedInstallation;
|
|
1937
|
+
}
|
|
1938
|
+
const packageId = storedDataValueV1(installation, "packageId");
|
|
1939
|
+
const version = storedDataValueV1(installation, "version");
|
|
1940
|
+
if (typeof packageId !== "string" || typeof version !== "string") {
|
|
1941
|
+
return storedInstallation;
|
|
1942
|
+
}
|
|
1943
|
+
const pkg = availableVersions.get(`${packageId}\u0000${version}`);
|
|
1944
|
+
if (
|
|
1945
|
+
!pkg ||
|
|
1946
|
+
Object.keys(pkg.dependencies ?? {}).every((id) => enabledIds.has(id))
|
|
1947
|
+
) {
|
|
1948
|
+
return storedInstallation;
|
|
1949
|
+
}
|
|
1950
|
+
changed = true;
|
|
1951
|
+
disabledInPass = true;
|
|
1952
|
+
return cloneStoredRecordV1(installation, {
|
|
1953
|
+
state: "disabled",
|
|
1954
|
+
failure: undefined,
|
|
1955
|
+
});
|
|
1956
|
+
});
|
|
1957
|
+
if (!disabledInPass) break;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
const connections = storedConnections.filter((storedConnection) => {
|
|
1961
|
+
const connection = storedPlainRecordV1(storedConnection);
|
|
1962
|
+
if (!connection) return true;
|
|
1963
|
+
const packageId = storedDataValueV1(connection, "packageId");
|
|
1964
|
+
if (typeof packageId !== "string") return true;
|
|
1965
|
+
// Connections ordinarily outlive uninstalls. This one is removed only
|
|
1966
|
+
// because the migration retired its first-party owning installation in
|
|
1967
|
+
// the same pass, which is the evidence that it is the requested orphan.
|
|
1968
|
+
const retained = !retiredFirstPartyPackageIds.has(packageId);
|
|
1969
|
+
changed ||= !retained;
|
|
1970
|
+
return retained;
|
|
1971
|
+
});
|
|
1972
|
+
|
|
1973
|
+
let platformModel = storedDataValueV1(settings, "platformModel");
|
|
1974
|
+
if (platformModel !== undefined) {
|
|
1975
|
+
let binding: ModelBindingV1 | undefined;
|
|
1976
|
+
try {
|
|
1977
|
+
binding = decodeModelBindingV1(platformModel);
|
|
1978
|
+
} catch {
|
|
1979
|
+
// This migration only repairs a valid old binding that is unavailable
|
|
1980
|
+
// against the current Catalog. Malformed current data must still reach
|
|
1981
|
+
// the exact decoder and fail visibly instead of being laundered away.
|
|
1982
|
+
}
|
|
1983
|
+
const connectionId = binding?.connectionId;
|
|
1984
|
+
const connection =
|
|
1985
|
+
typeof connectionId === "string"
|
|
1986
|
+
? connections.find((storedConnection) => {
|
|
1987
|
+
const candidate = storedPlainRecordV1(storedConnection);
|
|
1988
|
+
return (
|
|
1989
|
+
candidate &&
|
|
1990
|
+
storedDataValueV1(candidate, "connectionId") === connectionId
|
|
1991
|
+
);
|
|
1992
|
+
})
|
|
1993
|
+
: undefined;
|
|
1994
|
+
const connectionRecord = storedPlainRecordV1(connection);
|
|
1995
|
+
const ownerId = connectionRecord
|
|
1996
|
+
? storedDataValueV1(connectionRecord, "packageId")
|
|
1997
|
+
: undefined;
|
|
1998
|
+
const owner =
|
|
1999
|
+
typeof ownerId === "string"
|
|
2000
|
+
? installations.find((storedInstallation) => {
|
|
2001
|
+
const candidate = storedPlainRecordV1(storedInstallation);
|
|
2002
|
+
return (
|
|
2003
|
+
candidate &&
|
|
2004
|
+
storedDataValueV1(candidate, "packageId") === ownerId &&
|
|
2005
|
+
storedDataValueV1(candidate, "state") === "installed"
|
|
2006
|
+
);
|
|
2007
|
+
})
|
|
2008
|
+
: undefined;
|
|
2009
|
+
const ownerRecord = storedPlainRecordV1(owner);
|
|
2010
|
+
const ownerVersion = ownerRecord
|
|
2011
|
+
? storedDataValueV1(ownerRecord, "version")
|
|
2012
|
+
: undefined;
|
|
2013
|
+
const resolvable =
|
|
2014
|
+
connectionRecord !== undefined &&
|
|
2015
|
+
storedDataValueV1(connectionRecord, "state") === "ready" &&
|
|
2016
|
+
typeof ownerId === "string" &&
|
|
2017
|
+
typeof ownerVersion === "string" &&
|
|
2018
|
+
availableVersions.has(`${ownerId}\u0000${ownerVersion}`);
|
|
2019
|
+
if (binding && !resolvable) {
|
|
2020
|
+
changed = true;
|
|
2021
|
+
platformModel = undefined;
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
if (!changed) return settings;
|
|
2026
|
+
return cloneStoredRecordV1(
|
|
2027
|
+
settings,
|
|
2028
|
+
{ packages: installations, connections },
|
|
2029
|
+
platformModel === undefined && Object.hasOwn(settings, "platformModel")
|
|
2030
|
+
? ["platformModel"]
|
|
2031
|
+
: [],
|
|
2032
|
+
);
|
|
2033
|
+
}
|
|
2034
|
+
|
|
1864
2035
|
/**
|
|
1865
2036
|
* Migrates one raw User settings record across known durable shapes before the
|
|
1866
2037
|
* current exact-field decoder sees it. Commit 1571b62 removed the model
|
|
@@ -1868,7 +2039,10 @@ const PRE_ACCOUNT_WIDE_CONNECTION_METADATA_FIELDS_V1 = [
|
|
|
1868
2039
|
* with the Assignment feature; migration drops those fields without
|
|
1869
2040
|
* interpreting them.
|
|
1870
2041
|
*/
|
|
1871
|
-
export function migrateStoredUserSettingsV1(
|
|
2042
|
+
export function migrateStoredUserSettingsV1(
|
|
2043
|
+
stored: unknown,
|
|
2044
|
+
packages?: readonly StoredUserSettingsPackageV1[],
|
|
2045
|
+
): unknown {
|
|
1872
2046
|
const settings = storedPlainRecordV1(stored);
|
|
1873
2047
|
if (!settings || storedDataValueV1(settings, "schemaVersion") !== 1) {
|
|
1874
2048
|
return stored;
|
|
@@ -1909,12 +2083,16 @@ export function migrateStoredUserSettingsV1(stored: unknown): unknown {
|
|
|
1909
2083
|
}
|
|
1910
2084
|
}
|
|
1911
2085
|
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
2086
|
+
const migrated = changed
|
|
2087
|
+
? cloneStoredRecordV1(
|
|
2088
|
+
settings,
|
|
2089
|
+
connections === storedConnections ? {} : { connections },
|
|
2090
|
+
removedSettingsFields,
|
|
2091
|
+
)
|
|
2092
|
+
: settings;
|
|
2093
|
+
return packages
|
|
2094
|
+
? migrateCatalogRelativeUserSettingsV1(migrated, packages)
|
|
2095
|
+
: migrated;
|
|
1918
2096
|
}
|
|
1919
2097
|
|
|
1920
2098
|
export function decodeUserSettingsViewV1(input: unknown): UserSettingsViewV1 {
|