Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/features/Org2Cloud/org2CloudAccessSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,41 @@ describe("immutable update helpers", () => {
expect(byOrg[ORG].sessionVisibility).toEqual({});
});
});

describe("store resilience", () => {
it("sheds only corrupt entries at every record level", async () => {
const { vi } = await import("vitest");
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { CloudAccessSettingsByOrgSchema } =
await import("./org2CloudAccessSettings");
const parsed = CloudAccessSettingsByOrgSchema.parse({
"org-healthy": {
sessionModes: {
"session-shared": "full_replay",
"session-corrupt-mode": "not-a-mode",
},
sessionVisibility: {},
},
"org-corrupt": "garbage",
});

// The corrupt org entry is shed; the healthy org keeps its overrides —
// a whole-store reset would make every previously shared session
// resolve effective-off and retract its cloud row on the next pass.
expect(Object.keys(parsed)).toEqual(["org-healthy"]);
expect(
getEffectiveCloudAccessMode(
getCloudOrgAccessSettings(parsed, "org-healthy"),
"session-shared"
)
).toBe("full_replay");
// Inside the healthy org only the corrupt session entry is gone.
expect(
getEffectiveCloudAccessMode(
getCloudOrgAccessSettings(parsed, "org-healthy"),
"session-corrupt-mode"
)
).toBe("off");
warn.mockRestore();
});
});
30 changes: 23 additions & 7 deletions src/features/Org2Cloud/org2CloudAccessSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ import type {
CollabSessionAccessMode,
CollabSessionVisibility,
} from "@src/store/collaboration/types";
import { createZodJsonStorage } from "@src/util/core/storage/zodStorage";
import {
createZodJsonStorage,
tolerantRecordSchema,
} from "@src/util/core/storage/zodStorage";

const CloudAccessModeSchema = z.enum([
COLLAB_SESSION_ACCESS_MODE.OFF,
Expand All @@ -53,17 +56,30 @@ const CloudVisibilitySchema = z.enum([
COLLAB_SESSION_VISIBILITY.RESTRICTED,
]) satisfies z.ZodType<CollabSessionVisibility>;

/**
* Tolerant at every record level. This store is the privacy ratchet: a
* whole-store reset drops every explicit per-session override, and on the
* next pass previously shared sessions resolve effective-off and get their
* cloud rows RETRACTED — one corrupted byte silently unsharing the user's
* work. A corrupted entry must cost exactly that entry, never the store.
*/
const CloudOrgAccessSettingsSchema = z.object({
sessionModes: z.record(z.string(), CloudAccessModeSchema),
sessionVisibility: z.record(z.string(), CloudVisibilitySchema),
sessionModes: tolerantRecordSchema(
"session access mode",
CloudAccessModeSchema
),
sessionVisibility: tolerantRecordSchema(
"session visibility",
CloudVisibilitySchema
),
});

export type CloudOrgAccessSettings = z.output<
typeof CloudOrgAccessSettingsSchema
>;

const CloudAccessSettingsByOrgSchema = z.record(
z.string(),
export const CloudAccessSettingsByOrgSchema = tolerantRecordSchema(
"access-settings org",
CloudOrgAccessSettingsSchema
);

Expand All @@ -85,8 +101,8 @@ org2CloudAccessSettingsAtom.debugLabel = "org2CloudAccessSettingsAtom";
// Org sharing FLOOR (admin policy mirror, 0002)
// ============================================================================

const CloudSharingFloorByOrgSchema = z.record(
z.string(),
const CloudSharingFloorByOrgSchema = tolerantRecordSchema(
"sharing floor",
CloudAccessModeSchema
);

Expand Down
58 changes: 27 additions & 31 deletions src/features/Org2Cloud/org2CloudSyncAtoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
import { atomWithStorage } from "jotai/utils";
import { z } from "zod/v4";

import { createZodJsonStorage } from "@src/util/core/storage/zodStorage";
import {
createZodJsonStorage,
tolerantRecordSchema,
} from "@src/util/core/storage/zodStorage";

import { MERKLE_FRONTIER_MAX_HEIGHT } from "./org2CloudMerkleFrontier";

Expand Down Expand Up @@ -79,7 +82,10 @@ export interface ImportedReplayCheckpoint {
frozenHashFrontier: Array<string | null>;
}

const RepoScopesSchema = z.record(z.string(), z.array(z.string()));
const RepoScopesSchema = tolerantRecordSchema(
"repo scope",
z.array(z.string())
);

/** Cloud orgId → locally-known repo scopes (normalized remote keys). */
export const org2CloudRepoScopesAtom = atomWithStorage<
Expand All @@ -89,7 +95,10 @@ export const org2CloudRepoScopesAtom = atomWithStorage<
});
org2CloudRepoScopesAtom.debugLabel = "org2CloudRepoScopesAtom";

const SyncEnabledSchema = z.record(z.string(), z.boolean());
const SyncEnabledSchema = tolerantRecordSchema(
"sync-enabled flag",
z.boolean()
);

/** Cloud orgId → sync toggle; missing key = enabled (default ON). */
export const org2CloudSyncEnabledAtom = atomWithStorage<
Expand Down Expand Up @@ -125,33 +134,14 @@ const CloudPushCursorSchema = z.object({
}) satisfies z.ZodType<CollabSessionPushCursor>;

/**
* Per-entry tolerant store parse. `createZodJsonStorage` answers a failed
* whole-store parse with the initial value — for this store that would reset
* EVERY push cursor, and a full reset re-anchors every previously pushed
* session through an epoch rewrite on its next pass (fleet-wide churn in the
* #608 shape). One malformed entry (disk corruption, or a future checkpoint
* version rolled back to this build) must instead cost exactly one cursor:
* losing one is the designed recovery — that session alone re-anchors
* through the server OCC check.
* Per-entry tolerant: a whole-store reset would re-anchor every pushed
* session through an epoch rewrite (fleet-wide churn in the #608 shape);
* dropping one cursor re-anchors one session, the designed recovery.
*/
export const CloudPushCursorsSchema = z
.record(z.string(), z.unknown())
.transform((entries) => {
const cursors: Record<string, CollabSessionPushCursor> = {};
for (const [key, value] of Object.entries(entries)) {
const parsed = CloudPushCursorSchema.safeParse(value);
if (parsed.success) {
cursors[key] = parsed.data;
} else {
// Rate limiting is unnecessary: this runs once per storage load.
console.warn(
`[org2CloudSyncAtoms] dropped invalid push cursor "${key}"; ` +
"its session re-anchors on the next pass"
);
}
}
return cursors;
});
export const CloudPushCursorsSchema = tolerantRecordSchema(
"push cursor",
CloudPushCursorSchema
);

/** Keyed by `${orgId}:${sessionId}` (cloud org ids, no collision risk). */
export const org2CloudPushCursorsAtom = atomWithStorage<
Expand All @@ -164,7 +154,10 @@ export const org2CloudPushCursorsAtom = atomWithStorage<
);
org2CloudPushCursorsAtom.debugLabel = "org2CloudPushCursorsAtom";

const PushedMetadataSchema = z.record(z.string(), z.literal(true));
const PushedMetadataSchema = tolerantRecordSchema(
"pushed-metadata marker",
z.literal(true)
);

/**
* Persisted "we put a live metadata row on the server" marker, keyed
Expand All @@ -185,7 +178,10 @@ export const org2CloudPushedMetadataAtom = atomWithStorage<
);
org2CloudPushedMetadataAtom.debugLabel = "org2CloudPushedMetadataAtom";

const CollabStateCursorsSchema = z.record(z.string(), z.string());
const CollabStateCursorsSchema = tolerantRecordSchema(
"collab state cursor",
z.string()
);

/**
* Cloud orgId → ISO delta cursor for `cloud_list_org_collab_state`
Expand Down
21 changes: 20 additions & 1 deletion src/features/TeamCollaboration/sessionOrgTagsAtom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,37 @@
* source adapter at push time).
*/
import { atomWithStorage } from "jotai/utils";
import { z } from "zod/v4";

import {
buildCloudOrgSelectorValue,
parseCloudOrgSelectorValue,
} from "@src/features/Org2Cloud/org2CloudOrgsAtom";
import {
createZodJsonStorage,
tolerantRecordSchema,
} from "@src/util/core/storage/zodStorage";

/** sessionId → list of org tokens the session is explicitly tagged to. */
export type SessionOrgTags = Record<string, string[]>;

/**
* Per-entry tolerant, and validated at all — this store previously used the
* default JSON storage, where any parse failure resets EVERY tag. Losing a
* tag is not cosmetic: the push engine's ownership gate retracts the cloud
* row of a pushed session whose tag disappeared, so a whole-store reset
* silently unshares every explicitly moved session. A corrupted entry now
* costs exactly that session's tags.
*/
export const SessionOrgTagsSchema = tolerantRecordSchema(
"session org tag",
z.array(z.string())
);

export const sessionOrgTagsAtom = atomWithStorage<SessionOrgTags>(
"orgii:session-org-tags-v1",
{}
{},
createZodJsonStorage(SessionOrgTagsSchema)
);
sessionOrgTagsAtom.debugLabel = "sessionOrgTagsAtom";

Expand Down
42 changes: 41 additions & 1 deletion src/util/core/storage/zodStorage.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { z } from "zod/v4";

import { createZodJsonStorage } from "./zodStorage";
import { createZodJsonStorage, tolerantRecordSchema } from "./zodStorage";

const ListSchema = z.array(z.string());

Expand Down Expand Up @@ -62,3 +62,43 @@ describe("createZodJsonStorage", () => {
removeSpy.mockRestore();
});
});

describe("tolerantRecordSchema", () => {
it("drops only invalid entries and keeps the rest", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const schema = tolerantRecordSchema("thing", z.object({ n: z.number() }));
expect(
schema.parse({
good: { n: 1 },
wrongShape: { n: "nope" },
notAnObject: "garbage",
alsoGood: { n: 2 },
})
).toEqual({ good: { n: 1 }, alsoGood: { n: 2 } });
expect(warn).toHaveBeenCalledTimes(2);
warn.mockRestore();
});

it("parses an empty record and still fails non-record roots", () => {
const schema = tolerantRecordSchema("thing", z.string());
expect(schema.parse({})).toEqual({});
// A non-record root is unrecoverable garbage: the whole-store initial
// value fallback in createZodJsonStorage is the right behavior there.
expect(schema.safeParse("not-a-record").success).toBe(false);
expect(schema.safeParse(null).success).toBe(false);
});

it("composes through createZodJsonStorage so one bad entry never resets the store", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const storage = createZodJsonStorage(
tolerantRecordSchema("token list", z.array(z.string()))
);
localStorage.setItem(
"tags",
JSON.stringify({ keep: ["cloud:org-1"], drop: 42 })
);
expect(storage.getItem("tags", {})).toEqual({ keep: ["cloud:org-1"] });
expect(warn).toHaveBeenCalledTimes(1);
warn.mockRestore();
});
});
31 changes: 30 additions & 1 deletion src/util/core/storage/zodStorage.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,39 @@
import type { z } from "zod/v4";
import { z } from "zod/v4";

import {
removeBrowserStorageItemSafely,
setBrowserStorageItemWithRecovery,
} from "./quotaRecovery";

/**
* Record schema that parses every entry independently and DROPS invalid
* entries instead of failing the whole record. `createZodJsonStorage`
* answers a failed whole-store parse with the initial value, so for a
* record-shaped store one corrupted entry (disk damage, or a future
* entry shape rolled back onto this build) would otherwise reset EVERY
* entry at load — for cloud push state that scale of loss converts into
* fleet-wide re-anchors or retracts. Losing one entry is the designed,
* self-healing recovery; losing the store is an incident.
*/
export function tolerantRecordSchema<V>(
label: string,
valueSchema: z.ZodType<V>
) {
return z.record(z.string(), z.unknown()).transform((entries) => {
const valid: Record<string, V> = {};
for (const [key, value] of Object.entries(entries)) {
const parsed = valueSchema.safeParse(value);
if (parsed.success) {
valid[key] = parsed.data;
} else {
// Once per storage load per bad entry — no rate limit needed.
console.warn(`[zodStorage] dropped invalid ${label} entry "${key}"`);
}
}
return valid;
});
}

export interface ZodSyncStorage<T> {
getItem: (key: string, initialValue: T) => T;
setItem: (key: string, value: T) => void;
Expand Down
Loading