Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/calm-filters-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Expose atomic user-scoped setting mutations for ordered personal preferences.
1 change: 1 addition & 0 deletions packages/core/src/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export { readSetting, writeSetting, removeSetting } from "./script-helpers.js";
export {
getUserSetting,
putUserSetting,
mutateUserSetting,
deleteUserSetting,
} from "./user-settings.js";

Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/settings/user-settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
const mockGetSetting = vi.fn();
const mockPutSetting = vi.fn();
const mockDeleteSetting = vi.fn();
const mockMutateSetting = vi.fn();

vi.mock("./store.js", () => ({
getSetting: (...args: any[]) => mockGetSetting(...args),
putSetting: (...args: any[]) => mockPutSetting(...args),
deleteSetting: (...args: any[]) => mockDeleteSetting(...args),
mutateSetting: (...args: any[]) => mockMutateSetting(...args),
}));

import {
getUserSetting,
mutateUserSetting,
putUserSetting,
deleteUserSetting,
} from "./user-settings.js";
Expand Down Expand Up @@ -80,6 +83,29 @@ describe("user-settings", () => {
});
});

describe("mutateUserSetting", () => {
it("atomically mutates only the prefixed user key", async () => {
const updater = vi.fn((current) => ({
count: Number(current?.count ?? 0) + 1,
}));
mockMutateSetting.mockResolvedValue({ count: 2 });

const result = await mutateUserSetting(
"alice@test.com",
"counter",
updater,
{ requestSource: "tab-1" },
);

expect(mockMutateSetting).toHaveBeenCalledWith(
"u:alice@test.com:counter",
updater,
{ requestSource: "tab-1" },
);
expect(result).toEqual({ count: 2 });
});
});

describe("deleteUserSetting", () => {
it("prefixes key with u:<email>:", async () => {
mockDeleteSetting.mockResolvedValue(true);
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/settings/user-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getSetting,
putSetting,
deleteSetting,
mutateSetting,
type StoreWriteOptions,
} from "./store.js";

Expand All @@ -37,6 +38,18 @@ export async function putUserSetting(
return putSetting(userKey(email, key), value, options);
}

/** Atomically derive and persist one user-scoped setting. */
export async function mutateUserSetting(
email: string,
key: string,
updater: (
current: Record<string, unknown> | null,
) => Record<string, unknown> | Promise<Record<string, unknown>>,
options?: StoreWriteOptions,
): Promise<Record<string, unknown>> {
return mutateSetting(userKey(email, key), updater, options);
}

/** Delete a user-scoped setting. */
export async function deleteUserSetting(
email: string,
Expand Down
53 changes: 51 additions & 2 deletions templates/content/actions/_content-database-personal-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,51 @@ const legacyPersonalViewOverridesSchema = z.object({
...personalViewOverridesFields,
});

const storedPersonalViewStateSchema = z.object({
storageVersion: z.literal(1),
overrides: personalViewOverridesSchema.nullable(),
mutationSequences: z.record(z.string(), z.number().int().nonnegative()),
});

export function normalizeStoredPersonalDatabaseViewState(
stored: Record<string, unknown> | null,
) {
const state = storedPersonalViewStateSchema.safeParse(stored);
if (state.success) return state.data;
const overrides = personalViewOverridesSchema.safeParse(stored);
return {
storageVersion: 1 as const,
overrides: overrides.success ? overrides.data : null,
mutationSequences: {} as Record<string, number>,
};
}

export function orderedPersonalDatabaseViewState(args: {
current: Record<string, unknown> | null;
mutationSource: string;
mutationSequence: number;
overrides: z.infer<typeof personalViewOverridesSchema> | null;
}) {
const current = normalizeStoredPersonalDatabaseViewState(args.current);
if (
args.mutationSequence <=
(current.mutationSequences[args.mutationSource] ?? -1)
) {
return current;
}
const recentSources = Object.entries(current.mutationSequences)
.filter(([source]) => source !== args.mutationSource)
.slice(-15);
return {
storageVersion: 1 as const,
overrides: args.overrides,
mutationSequences: Object.fromEntries([
...recentSources,
[args.mutationSource, args.mutationSequence],
]),
};
}

export async function assertContentDatabaseViewerAccess(databaseId: string) {
const db = getDb();
const [database] = await db
Expand Down Expand Up @@ -93,10 +138,14 @@ export async function readPersonalDatabaseViewOverrides(
userEmail,
personalDatabaseViewSettingKey(databaseId),
);
const parsed = personalViewOverridesSchema.safeParse(stored);
const storedState = storedPersonalViewStateSchema.safeParse(stored);
const storedOverrides = storedState.success
? storedState.data.overrides
: stored;
const parsed = personalViewOverridesSchema.safeParse(storedOverrides);
if (parsed.success) return parsed.data;

const legacy = legacyPersonalViewOverridesSchema.safeParse(stored);
const legacy = legacyPersonalViewOverridesSchema.safeParse(storedOverrides);
if (!legacy.success) return null;
const [database] = await getDb()
.select({ systemRole: schema.contentDatabases.systemRole })
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";

import { PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION } from "./_content-database-personal-view";
import {
PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION,
normalizeStoredPersonalDatabaseViewState,
orderedPersonalDatabaseViewState,
} from "./_content-database-personal-view";
import action from "./update-content-database-personal-view";

describe("update content database personal view", () => {
Expand Down Expand Up @@ -44,4 +48,75 @@ describe("update content database personal view", () => {
}).overrides,
).toBeNull();
});

it("requires mutation ordering fields together", () => {
expect(() =>
action.schema.parse({
databaseId: "database",
overrides: null,
mutationSource: "tab-1",
}),
).toThrow(/mutationSource and mutationSequence/);
});

it("keeps the newest override when requests arrive out of order", () => {
const first = orderedPersonalDatabaseViewState({
current: null,
mutationSource: "tab-1",
mutationSequence: 2,
overrides: personalOverrides("newest"),
});
const stale = orderedPersonalDatabaseViewState({
current: first,
mutationSource: "tab-1",
mutationSequence: 1,
overrides: personalOverrides("stale"),
});

expect(
normalizeStoredPersonalDatabaseViewState(stale).overrides?.views[0]
?.filters[0]?.value,
).toBe("newest");
});

it("keeps an ordered clear as a tombstone against a stale write", () => {
const cleared = orderedPersonalDatabaseViewState({
current: null,
mutationSource: "tab-1",
mutationSequence: 2,
overrides: null,
});
const stale = orderedPersonalDatabaseViewState({
current: cleared,
mutationSource: "tab-1",
mutationSequence: 1,
overrides: personalOverrides("stale"),
});

expect(
normalizeStoredPersonalDatabaseViewState(stale).overrides,
).toBeNull();
});
});

function personalOverrides(value: string) {
return {
version: PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION,
activeViewId: "table",
views: [
{
id: "table",
sorts: [],
filters: [
{
key: "name",
label: "Name",
operator: "contains" as const,
value,
},
],
filterMode: "and" as const,
},
],
};
}
53 changes: 43 additions & 10 deletions templates/content/actions/update-content-database-personal-view.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,63 @@
import { defineAction } from "@agent-native/core";
import { deleteUserSetting, putUserSetting } from "@agent-native/core/settings";
import { mutateUserSetting } from "@agent-native/core/settings";
import { z } from "zod";

import {
assertContentDatabaseViewerAccess,
normalizeStoredPersonalDatabaseViewState,
orderedPersonalDatabaseViewState,
personalDatabaseViewSettingKey,
personalViewOverridesSchema,
} from "./_content-database-personal-view.js";

export default defineAction({
description:
"Update or clear the current user's personal saved filter, sort, and active view overrides for a content database.",
schema: z.object({
databaseId: z.string().describe("Database ID"),
overrides: personalViewOverridesSchema.nullable(),
}),
run: async ({ databaseId, overrides }, ctx) => {
schema: z
.object({
databaseId: z.string().describe("Database ID"),
overrides: personalViewOverridesSchema.nullable(),
mutationSource: z.string().min(1).max(200).optional(),
mutationSequence: z.number().int().nonnegative().optional(),
})
.refine(
({ mutationSource, mutationSequence }) =>
(mutationSource == null) === (mutationSequence == null),
{
message:
"mutationSource and mutationSequence must be provided together.",
},
),
run: async (
{ databaseId, overrides, mutationSource, mutationSequence },
ctx,
) => {
if (!ctx?.userEmail) throw new Error("Not authenticated.");
await assertContentDatabaseViewerAccess(databaseId);

const key = personalDatabaseViewSettingKey(databaseId);
if (overrides) {
await putUserSetting(ctx.userEmail, key, overrides);
} else {
await deleteUserSetting(ctx.userEmail, key);
if (mutationSource != null && mutationSequence != null) {
const stored = await mutateUserSetting(
ctx.userEmail,
key,
(current) =>
orderedPersonalDatabaseViewState({
current,
mutationSource,
mutationSequence,
overrides,
}),
{ requestSource: mutationSource },
);
return {
databaseId,
overrides: normalizeStoredPersonalDatabaseViewState(stored).overrides,
};
}
await mutateUserSetting(ctx.userEmail, key, (current) => ({
...normalizeStoredPersonalDatabaseViewState(current),
overrides,
}));

return { databaseId, overrides };
},
Expand Down
Loading
Loading