@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/configuration-core",
3
- "version": "0.2.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.2",
16
- "@frockbot/kernel-composition": "0.2.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(stored: unknown): unknown {
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
- if (!changed) return stored;
1913
- return cloneStoredRecordV1(
1914
- settings,
1915
- connections === storedConnections ? {} : { connections },
1916
- removedSettingsFields,
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 {