diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 3d78b19..d4b05ce 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -61,3 +61,4 @@ jobs: - name: Install Playwright Deps run: npx playwright install --with-deps - run: npm run test + - run: npm run test-types diff --git a/.gitignore b/.gitignore index 3dac2c6..7091470 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ out node_modules tsconfig.tsbuildinfo +tsconfig.vitest-temp.json diff --git a/package-lock.json b/package-lock.json index 063bd47..2bfeca0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@rocicorp/logger": "^5.2.1", "@rocicorp/prettier-config": "^0.2.0", "@rocicorp/reflect": "^0.39.202401100534", - "@vitest/browser": "^1.2.1", + "@vitest/browser": "1.2.2", "@web/dev-server": "^0.4.1", "@web/dev-server-esbuild": "^1.0.1", "@web/dev-server-import-maps": "^0.2.0", @@ -21,9 +21,9 @@ "@web/test-runner-playwright": "^0.11.0", "nanoid": "^5.0.4", "playwright": "^1.41.1", - "replicache": "14.0.3", + "replicache": "14.1.0", "typescript": "^5.3.3", - "vitest": "^1.2.1", + "vitest": "1.2.2", "zod": "^3.22.4" } }, @@ -1603,12 +1603,12 @@ "dev": true }, "node_modules/@vitest/browser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-1.2.1.tgz", - "integrity": "sha512-jhaQ15zWYAwz8anXgmLW0yAVLCXdT8RFv7LeW9bg7sMlvGJaTCTIHaHWFvCdADF/i62+22tnrzgiiqSnApjXtA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-1.2.2.tgz", + "integrity": "sha512-N8myxNVLbS9AbZ7B2cK33HTGYVzUTDArbMh3hLojOxaj7s7ZrBYYmzs0Q5J2wyDrOgs51p6OUrrzAIb1Z+Ck3A==", "dev": true, "dependencies": { - "@vitest/utils": "1.2.1", + "@vitest/utils": "1.2.2", "magic-string": "^0.30.5", "sirv": "^2.0.4" }, @@ -1633,13 +1633,13 @@ } }, "node_modules/@vitest/expect": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.2.1.tgz", - "integrity": "sha512-/bqGXcHfyKgFWYwIgFr1QYDaR9e64pRKxgBNWNXPefPFRhgm+K3+a/dS0cUGEreWngets3dlr8w8SBRw2fCfFQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.2.2.tgz", + "integrity": "sha512-3jpcdPAD7LwHUUiT2pZTj2U82I2Tcgg2oVPvKxhn6mDI2On6tfvPQTjAI4628GUGDZrCm4Zna9iQHm5cEexOAg==", "dev": true, "dependencies": { - "@vitest/spy": "1.2.1", - "@vitest/utils": "1.2.1", + "@vitest/spy": "1.2.2", + "@vitest/utils": "1.2.2", "chai": "^4.3.10" }, "funding": { @@ -1647,12 +1647,12 @@ } }, "node_modules/@vitest/runner": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.2.1.tgz", - "integrity": "sha512-zc2dP5LQpzNzbpaBt7OeYAvmIsRS1KpZQw4G3WM/yqSV1cQKNKwLGmnm79GyZZjMhQGlRcSFMImLjZaUQvNVZQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.2.2.tgz", + "integrity": "sha512-JctG7QZ4LSDXr5CsUweFgcpEvrcxOV1Gft7uHrvkQ+fsAVylmWQvnaAr/HDp3LAH1fztGMQZugIheTWjaGzYIg==", "dev": true, "dependencies": { - "@vitest/utils": "1.2.1", + "@vitest/utils": "1.2.2", "p-limit": "^5.0.0", "pathe": "^1.1.1" }, @@ -1688,9 +1688,9 @@ } }, "node_modules/@vitest/snapshot": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.2.1.tgz", - "integrity": "sha512-Tmp/IcYEemKaqAYCS08sh0vORLJkMr0NRV76Gl8sHGxXT5151cITJCET20063wk0Yr/1koQ6dnmP6eEqezmd/Q==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.2.2.tgz", + "integrity": "sha512-SmGY4saEw1+bwE1th6S/cZmPxz/Q4JWsl7LvbQIky2tKE35US4gd0Mjzqfr84/4OD0tikGWaWdMja/nWL5NIPA==", "dev": true, "dependencies": { "magic-string": "^0.30.5", @@ -1702,9 +1702,9 @@ } }, "node_modules/@vitest/spy": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.2.1.tgz", - "integrity": "sha512-vG3a/b7INKH7L49Lbp0IWrG6sw9j4waWAucwnksPB1r1FTJgV7nkBByd9ufzu6VWya/QTvQW4V9FShZbZIB2UQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.2.2.tgz", + "integrity": "sha512-k9Gcahssw8d7X3pSLq3e3XEu/0L78mUkCjivUqCQeXJm9clfXR/Td8+AP+VC1O6fKPIDLcHDTAmBOINVuv6+7g==", "dev": true, "dependencies": { "tinyspy": "^2.2.0" @@ -1714,9 +1714,9 @@ } }, "node_modules/@vitest/utils": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.2.1.tgz", - "integrity": "sha512-bsH6WVZYe/J2v3+81M5LDU8kW76xWObKIURpPrOXm2pjBniBu2MERI/XP60GpS4PHU3jyK50LUutOwrx4CyHUg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.2.2.tgz", + "integrity": "sha512-WKITBHLsBHlpjnDQahr+XK6RE7MiAsgrIkr0pGhQ9ygoxBfUeG0lUG5iLlzqjmKSlBv3+j5EGsriBzh+C3Tq9g==", "dev": true, "dependencies": { "diff-sequences": "^29.6.3", @@ -5796,9 +5796,9 @@ } }, "node_modules/replicache": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/replicache/-/replicache-14.0.3.tgz", - "integrity": "sha512-BXj8Wg2LS15h+3H66ws5XDHlNT88mLjqEkvxhecg4OaqnfNMBJRr5/gM9Chu4gVciH5w90G1X6JTsshgvW2FBg==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/replicache/-/replicache-14.1.0.tgz", + "integrity": "sha512-hFCnTBvFTw4j/cggSlzPkDpFkXnYGgSI18qYBTRtPR24m+J92dGvorGqh9+CFvzeSxF+VTf7zeAvF/MKqb3y+w==", "dev": true, "dependencies": { "@badrap/valita": "^0.3.0", @@ -6800,9 +6800,9 @@ } }, "node_modules/vite-node": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.2.1.tgz", - "integrity": "sha512-fNzHmQUSOY+y30naohBvSW7pPn/xn3Ib/uqm+5wAJQJiqQsU0NBR78XdRJb04l4bOFKjpTWld0XAfkKlrDbySg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.2.2.tgz", + "integrity": "sha512-1as4rDTgVWJO3n1uHmUYqq7nsFgINQ9u+mRcXpjeOMJUmviqNKjcZB7UfRZrlM7MjYXMKpuWp5oGkjaFLnjawg==", "dev": true, "dependencies": { "cac": "^6.7.14", @@ -6822,16 +6822,16 @@ } }, "node_modules/vitest": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.2.1.tgz", - "integrity": "sha512-TRph8N8rnSDa5M2wKWJCMnztCZS9cDcgVTQ6tsTFTG/odHJ4l5yNVqvbeDJYJRZ6is3uxaEpFs8LL6QM+YFSdA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.2.2.tgz", + "integrity": "sha512-d5Ouvrnms3GD9USIK36KG8OZ5bEvKEkITFtnGv56HFaSlbItJuYr7hv2Lkn903+AvRAgSixiamozUVfORUekjw==", "dev": true, "dependencies": { - "@vitest/expect": "1.2.1", - "@vitest/runner": "1.2.1", - "@vitest/snapshot": "1.2.1", - "@vitest/spy": "1.2.1", - "@vitest/utils": "1.2.1", + "@vitest/expect": "1.2.2", + "@vitest/runner": "1.2.2", + "@vitest/snapshot": "1.2.2", + "@vitest/spy": "1.2.2", + "@vitest/utils": "1.2.2", "acorn-walk": "^8.3.2", "cac": "^6.7.14", "chai": "^4.3.10", @@ -6844,9 +6844,9 @@ "std-env": "^3.5.0", "strip-literal": "^1.3.0", "tinybench": "^2.5.1", - "tinypool": "^0.8.1", + "tinypool": "^0.8.2", "vite": "^5.0.0", - "vite-node": "1.2.1", + "vite-node": "1.2.2", "why-is-node-running": "^2.2.2" }, "bin": { diff --git a/package.json b/package.json index 64f859c..2e1c4ea 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,10 @@ "lint": "eslint --ext .ts,.tsx,.js,.jsx src/", "build": "tsc", "prepack": "npm run check-format && npm run lint && npm run test && npm run build", - "test": "vitest run --browser.provider=playwright --browser.name=chromium --browser.headless", - "test:watch": "vitest watch --browser.provider=playwright --browser.name=chromium --browser.headless" + "test": "vitest run", + "test:watch": "vitest watch", + "test-types": "vitest run --typecheck.only --no-browser.enabled", + "test-types:watch": "vitest watch --typecheck.only --no-browser.enabled" }, "author": "", "license": "Apache-2.0", @@ -30,12 +32,12 @@ "@web/test-runner": "^0.18.0", "@web/test-runner-playwright": "^0.11.0", "nanoid": "^5.0.4", - "replicache": "14.0.3", + "replicache": "14.1.0", "typescript": "^5.3.3", "zod": "^3.22.4", - "@vitest/browser": "^1.2.1", + "@vitest/browser": "1.2.2", "playwright": "^1.41.1", - "vitest": "^1.2.1" + "vitest": "1.2.2" }, "files": [ "out/", diff --git a/src/generate-presence.test-d.ts b/src/generate-presence.test-d.ts new file mode 100644 index 0000000..25028ed --- /dev/null +++ b/src/generate-presence.test-d.ts @@ -0,0 +1,339 @@ +import {assertType, expectTypeOf, test} from 'vitest'; +import {z} from 'zod'; +import {generatePresence} from './generate-presence.js'; +import {ReadTransaction, WriteTransaction} from './generate.js'; + +const entryNoID = z + .object({ + clientID: z.string(), + str: z.string(), + optStr: z.string().optional(), + }) + .strict(); + +type EntryNoID = z.infer; + +const { + init: initEntryNoID, + set: setEntryNoID, + update: updateEntryNoID, + delete: deleteEntryNoID, + get: getEntryNoID, + mustGet: mustGetEntryNoID, + has: hasEntryNoID, + list: listEntryNoID, + listIDs: listIDsEntryNoID, + listClientIDs: listClientIDsEntryNoID, + listEntries: listEntriesEntryNoID, +} = generatePresence('entryNoID', entryNoID.parse); + +const entryID = z + .object({ + clientID: z.string(), + id: z.string(), + str: z.string(), + optStr: z.string().optional(), + }) + .strict(); + +type EntryID = z.infer; + +const { + init: initEntryID, + set: setEntryID, + update: updateEntryID, + delete: deleteEntryID, + get: getEntryID, + mustGet: mustGetEntryID, + has: hasEntryID, + list: listEntryID, + listIDs: listIDsEntryID, + listClientIDs: listClientIDsEntryID, + listEntries: listEntriesEntryID, +} = generatePresence('entryID', entryID.parse); + +declare const rtx: ReadTransaction; +declare const wtx: WriteTransaction; + +test('init', async () => { + await initEntryID(wtx, {clientID: 'foo', id: 'bar', str: 'baz'}); + await initEntryID(wtx, {id: 'bar', str: 'baz'}); + // @ts-expect-error missing str + await initEntryID(wtx, {id: 'bar'}); + // @ts-expect-error missing id + await initEntryID(wtx, {clientID: 'foo', str: 'baz'}); + expectTypeOf(initEntryID).returns.resolves.toBeBoolean(); + + await initEntryNoID(wtx, {clientID: 'foo', str: 'baz'}); + await initEntryNoID(wtx, {str: 'baz'}); + // @ts-expect-error Type 'number' is not assignable to type 'string'. + await initEntryNoID(wtx, {clientID: 'foo', str: 42}); + // @ts-expect-error missing str + await initEntryNoID(wtx, {}); + expectTypeOf(initEntryNoID).returns.resolves.toBeBoolean(); +}); + +test('update', async () => { + await updateEntryID(wtx, {clientID: 'foo', id: 'bar', str: 'baz'}); + await updateEntryID(wtx, {id: 'bar', str: 'baz'}); + await updateEntryID(wtx, {id: 'bar'}); + // @ts-expect-error missing id + await updateEntryID(wtx, {clientID: 'foo', str: 'baz'}); + expectTypeOf(updateEntryID).returns.resolves.toBeVoid(); + + await updateEntryNoID(wtx, {clientID: 'foo', str: 'baz'}); + await updateEntryNoID(wtx, {str: 'baz'}); + // @ts-expect-error Type 'number' is not assignable to type 'string'. + await updateEntryNoID(wtx, {clientID: 'foo', str: 42}); + + expectTypeOf(updateEntryNoID).returns.resolves.toBeVoid(); +}); + +test('delete', async () => { + await deleteEntryID(wtx, {clientID: 'cid', id: 'bar'}); + await deleteEntryID(wtx, {id: 'bar'}); + expectTypeOf(deleteEntryID) + .parameter(1) + .toEqualTypeOf<{clientID?: string | undefined; id: string} | undefined>(); + expectTypeOf(deleteEntryID).returns.resolves.toBeVoid(); + + // @ts-expect-error extra str + await deleteEntryID(wtx, {clientID: 'foo', id: 'bar', str: 'baz'}); + // @ts-expect-error extra str + await deleteEntryID(wtx, {id: 'bar', str: 'baz'}); + // @ts-expect-error missing id + await deleteEntryID(wtx, {clientID: 'foo'}); + // @ts-expect-error missing id + await deleteEntryID(wtx, {}); + expectTypeOf(deleteEntryID) + .parameter(1) + .not.toEqualTypeOf<{clientID?: string | undefined} | undefined>(); + + await deleteEntryNoID(wtx, {clientID: 'foo'}); + await deleteEntryNoID(wtx, {}); + await deleteEntryNoID(wtx, undefined); + await deleteEntryNoID(wtx); + expectTypeOf(deleteEntryNoID) + .parameter(1) + .toMatchTypeOf<{clientID?: string | undefined} | undefined>(); + expectTypeOf(deleteEntryNoID).returns.resolves.toBeVoid(); + + // @ts-expect-error extra str + await deleteEntryNoID(wtx, {clientID: 'foo', str: 'baz'}); + // @ts-expect-error extra str + await deleteEntryNoID(wtx, {str: 'baz'}); + // @ts-expect-error Type 'number' is not assignable to type 'string'. + await deleteEntryNoID(wtx, {clientID: 'foo', str: 42}); + expectTypeOf(deleteEntryNoID) + .parameter(1) + .not.toEqualTypeOf< + {clientID?: string | undefined; id: string} | undefined + >(); +}); + +test('set', () => { + assertType< + ( + tx: WriteTransaction, + id: {clientID?: string; id: string; str: string; optStr?: string}, + ) => Promise + >(setEntryID); + + assertType< + ( + tx: WriteTransaction, + id: {clientID?: string; str: string; optStr?: string}, + ) => Promise + >(setEntryNoID); +}); + +test('get', () => { + assertType< + ( + tx: ReadTransaction, + id: {clientID?: string} | undefined, + ) => Promise + >(getEntryNoID); + + assertType< + ( + tx: ReadTransaction, + id: {clientID?: string; id: string}, + ) => Promise + >(getEntryID); + expectTypeOf(getEntryID).not.toMatchTypeOf< + ( + tx: ReadTransaction, + id: {clientID?: string}, + ) => Promise + >(); + + assertType< + ( + tx: ReadTransaction, + id: {clientID?: string}, + ) => Promise + // @ts-expect-error missing id + >(getEntryID); + + expectTypeOf(getEntryNoID) + .parameter(1) + .toEqualTypeOf<{clientID?: string} | undefined>(); + expectTypeOf(getEntryNoID).returns.resolves.toEqualTypeOf< + EntryNoID | undefined + >(); + + expectTypeOf(getEntryID) + .parameter(1) + .toEqualTypeOf<{clientID?: string; id: string} | undefined>(); + expectTypeOf(getEntryID).returns.resolves.toEqualTypeOf< + EntryID | undefined + >(); +}); + +test('mustGet', () => { + expectTypeOf(mustGetEntryNoID) + .parameter(1) + .toEqualTypeOf<{clientID?: string} | undefined>(); + expectTypeOf(mustGetEntryNoID).returns.resolves.not.toBeUndefined(); + expectTypeOf(mustGetEntryNoID).returns.resolves.toEqualTypeOf(); + + expectTypeOf(mustGetEntryID) + .parameter(1) + .toEqualTypeOf<{clientID?: string; id: string} | undefined>(); + expectTypeOf(mustGetEntryID).returns.resolves.not.toBeUndefined(); + expectTypeOf(mustGetEntryID).returns.resolves.toEqualTypeOf(); +}); + +test('has', async () => { + await hasEntryNoID(rtx, {clientID: 'foo'}); + await hasEntryNoID(rtx, {}); + await hasEntryNoID(rtx, undefined); + await hasEntryNoID(rtx); + assertType< + ( + tx: ReadTransaction, + id?: {clientID?: string} | undefined, + ) => Promise + >(hasEntryNoID); + + // @ts-expect-error extra id + await hasEntryNoID(rtx, {id: 'bar'}); + // @ts-expect-error extra str + await hasEntryNoID(rtx, {str: 'bar'}); + + await hasEntryID(rtx, {clientID: 'foo', id: 'b'}); + await hasEntryID(rtx, {id: 'b'}); + + assertType< + ( + tx: ReadTransaction, + id: {clientID?: string; id: string}, + ) => Promise + >(hasEntryID); + + // @ts-expect-error missing id + await hasEntryID(rtx, {}); + // @ts-expect-error missing id + await hasEntryID(rtx, {clientID: 'foo'}); + // @ts-expect-error extra str + await hasEntryID(rtx, {str: 'bar'}); + + // TODO(arv): Fix these + // await hasEntryID(rtx, undefined); + // await hasEntryID(rtx); +}); + +test('list', async () => { + assertType(await listEntryNoID(rtx)); + await listEntryNoID(rtx, {}); + await listEntryNoID(rtx, {limit: 1}); + await listEntryNoID(rtx, {startAtID: {clientID: 'cid'}}); + await listEntryNoID(rtx, {startAtID: {clientID: 'cid'}, limit: 0}); + // @ts-expect-error unknown property foo + await listEntryNoID(rtx, {startAtID: {clientID: 'cid', foo: 'bar'}}); + // @ts-expect-error unknown property id + await listEntryNoID(rtx, {startAtID: {clientID: 'cid', id: 'bar'}}); + + assertType(await listEntryID(rtx)); + await listEntryID(rtx, {}); + await listEntryID(rtx, {limit: 1}); + await listEntryID(rtx, {startAtID: {clientID: 'cid'}}); + await listEntryID(rtx, {startAtID: {clientID: 'cid'}, limit: 0}); + await listEntryID(rtx, {startAtID: {clientID: 'cid', id: 'b'}}); + // @ts-expect-error unknown property foo + await listEntryID(rtx, {startAtID: {clientID: 'cid', id: 'b', foo: 'bar'}}); +}); + +test('listIDs', async () => { + assertType<{clientID: string}[]>(await listIDsEntryNoID(rtx)); + await listIDsEntryNoID(rtx, {}); + await listIDsEntryNoID(rtx, {limit: 1}); + await listIDsEntryNoID(rtx, {startAtID: {clientID: 'cid'}}); + await listIDsEntryNoID(rtx, {startAtID: {clientID: 'cid'}, limit: 0}); + // @ts-expect-error unknown property foo + await listIDsEntryNoID(rtx, {startAtID: {clientID: 'cid', foo: 'bar'}}); + // @ts-expect-error unknown property id + await listIDsEntryNoID(rtx, {startAtID: {clientID: 'cid', id: 'bar'}}); + + assertType<{clientID: string; id: string}[]>(await listIDsEntryID(rtx)); + await listIDsEntryID(rtx, {}); + await listIDsEntryID(rtx, {limit: 1}); + await listIDsEntryID(rtx, {startAtID: {clientID: 'cid'}}); + await listIDsEntryID(rtx, {startAtID: {clientID: 'cid'}, limit: 0}); + await listIDsEntryID(rtx, {startAtID: {clientID: 'cid', id: 'b'}}); + await listIDsEntryID(rtx, { + // @ts-expect-error unknown property foo + startAtID: {clientID: 'cid', id: 'b', foo: 'bar'}, + }); +}); + +test('listClientIDs', async () => { + assertType(await listClientIDsEntryNoID(rtx)); + await listClientIDsEntryNoID(rtx, {}); + await listClientIDsEntryNoID(rtx, {limit: 1}); + await listClientIDsEntryNoID(rtx, {startAtID: {clientID: 'cid'}}); + await listClientIDsEntryNoID(rtx, {startAtID: {clientID: 'cid'}, limit: 0}); + // @ts-expect-error unknown property foo + await listClientIDsEntryNoID(rtx, {startAtID: {clientID: 'cid', foo: 'bar'}}); + // @ts-expect-error unknown property id + await listClientIDsEntryNoID(rtx, {startAtID: {clientID: 'cid', id: 'bar'}}); + + assertType(await listClientIDsEntryID(rtx)); + await listClientIDsEntryID(rtx, {}); + await listClientIDsEntryID(rtx, {limit: 1}); + await listClientIDsEntryID(rtx, {startAtID: {clientID: 'cid'}}); + await listClientIDsEntryID(rtx, {startAtID: {clientID: 'cid'}, limit: 0}); + await listClientIDsEntryID(rtx, {startAtID: {clientID: 'cid', id: 'b'}}); + await listClientIDsEntryID(rtx, { + // @ts-expect-error unknown property foo + startAtID: {clientID: 'cid', id: 'b', foo: 'bar'}, + }); +}); + +test('listEntries', async () => { + assertType<[{clientID: string}, EntryNoID][]>( + await listEntriesEntryNoID(rtx), + ); + await listEntriesEntryNoID(rtx, {}); + await listEntriesEntryNoID(rtx, {limit: 1}); + await listEntriesEntryNoID(rtx, {startAtID: {clientID: 'cid'}}); + await listEntriesEntryNoID(rtx, {startAtID: {clientID: 'cid'}, limit: 0}); + // @ts-expect-error unknown property foo + await listEntriesEntryNoID(rtx, {startAtID: {clientID: 'cid', foo: 'bar'}}); + // @ts-expect-error unknown property id + await listEntriesEntryNoID(rtx, {startAtID: {clientID: 'cid', id: 'bar'}}); + + assertType<[{clientID: string; id: string}, EntryID][]>( + await listEntriesEntryID(rtx), + ); + await listEntriesEntryID(rtx, {}); + await listEntriesEntryID(rtx, {limit: 1}); + await listEntriesEntryID(rtx, {startAtID: {clientID: 'cid'}}); + await listEntriesEntryID(rtx, {startAtID: {clientID: 'cid'}, limit: 0}); + await listEntriesEntryID(rtx, {startAtID: {clientID: 'cid', id: 'b'}}); + await listEntriesEntryID(rtx, { + // @ts-expect-error unknown property foo + startAtID: {clientID: 'cid', id: 'b', foo: 'bar'}, + }); +}); diff --git a/src/generate-presence.test.ts b/src/generate-presence.test.ts index ea97493..ce3b239 100644 --- a/src/generate-presence.test.ts +++ b/src/generate-presence.test.ts @@ -4,38 +4,77 @@ import {Reflect} from '@rocicorp/reflect/client'; import {nanoid} from 'nanoid'; import {MutatorDefs, Replicache, TEST_LICENSE_KEY} from 'replicache'; import {expect, suite, test} from 'vitest'; -import {ZodError, ZodTypeAny, z} from 'zod'; +import {ZodError, z} from 'zod'; import { - ListOptionsForPresence, + ListOptions, PresenceEntity, generatePresence, + keyFromID, normalizeScanOptions, parseKeyToID, } from './generate-presence.js'; -import {ListOptionsWithLookupID, WriteTransaction} from './generate.js'; -import {ReadonlyJSONObject, ReadonlyJSONValue} from './json.js'; - -const e1 = z.object({ - clientID: z.string(), - id: z.string(), - str: z.string(), - optStr: z.string().optional(), -}); +import {WriteTransaction} from './generate.js'; +import {ReadonlyJSONValue} from './json.js'; + +const entryNoID = z + .object({ + clientID: z.string(), + str: z.string(), + optStr: z.string().optional(), + }) + .strict(); -type E1 = z.infer; +type EntryNoID = z.infer; const { - init: initE1, - set: setE1, - update: updateE1, - delete: deleteE1, - get: getE1, - mustGet: mustGetE1, - has: hasE1, - list: listE1, - listIDs: listIDsE1, - listEntries: listEntriesE1, -} = generatePresence('e1', e1.parse); + init: initEntryNoID, + set: setEntryNoID, + update: updateEntryNoID, + delete: deleteEntryNoID, + get: getEntryNoID, + mustGet: mustGetEntryNoID, + has: hasEntryNoID, + list: listEntryNoID, + listIDs: listIDsEntryNoID, + listClientIDs: listClientIDsEntryNoID, + listEntries: listEntriesEntryNoID, +} = generatePresence('entryNoID', entryNoID.parse); + +const entryID = z + .object({ + clientID: z.string(), + id: z.string(), + str: z.string(), + optStr: z.string().optional(), + }) + .strict(); + +type EntryID = z.infer; + +const { + init: initEntryID, + set: setEntryID, + update: updateEntryID, + delete: deleteEntryID, + get: getEntryID, + mustGet: mustGetEntryID, + has: hasEntryID, + list: listEntryID, + listIDs: listIDsEntryID, + listClientIDs: listClientIDsEntryID, + listEntries: listEntriesEntryID, +} = generatePresence('entryID', entryID.parse); + +const collectionNames = ['entryNoID', 'entryID'] as const; + +function sameForBoth( + c: C, +): [C & {collectionName: 'entryNoID'}, C & {collectionName: 'entryID'}] { + return collectionNames.map(collectionName => ({...c, collectionName})) as [ + C & {collectionName: 'entryNoID'}, + C & {collectionName: 'entryID'}, + ]; +} async function directWrite( tx: WriteTransaction, @@ -45,12 +84,18 @@ async function directWrite( } const mutators = { - initE1, - setE1, - getE1, - updateE1, - deleteE1, - listE1, + initEntryNoID, + setEntryNoID, + updateEntryNoID, + deleteEntryNoID, + listEntryNoID, + + initEntryID, + setEntryID, + updateEntryID, + deleteEntryID, + listEntryID, + directWrite, }; @@ -67,111 +112,140 @@ const factories = [ userID: nanoid(), mutators: m, }), -]; +] as const; suite('set', () => { type Case = { name: string; - id: string; - preexisting: boolean; - input: (clientID: string) => unknown; - written?: (clientID: string) => unknown; - expectError?: (clientID: string) => unknown; + value: ReadonlyJSONValue | undefined; + expectedKey?: string; + expectedValue?: ReadonlyJSONValue; + expectError?: ReadonlyJSONValue; + collectionName: 'entryNoID' | 'entryID'; }; - const id = 'id1'; + const clientID = '$CLIENT_ID'; + const id = 'b'; const cases: Case[] = [ { - name: 'normalize id', - id: '', - preexisting: false, - input: clientID => ({clientID, str: 'foo'}), - written: clientID => ({clientID, id: '', str: 'foo'}), + name: 'set with clientID (no id)', + collectionName: 'entryNoID', + value: {clientID, str: 'foo'}, + expectedValue: {clientID, str: 'foo'}, + expectedKey: `-/p/${clientID}/entryNoID/`, }, { - name: 'normalize clientID', - id: 'id2', - preexisting: false, - input: () => ({id: 'id2', str: 'foo'}), - written: clientID => ({clientID, id: 'id2', str: 'foo'}), + name: 'set with clientID (no id)', + collectionName: 'entryID', + value: {clientID, str: 'foo'}, + expectError: {_errors: [], id: {_errors: ['Required']}}, }, + { - name: 'normalize clientID & id', - id: '', - preexisting: false, - input: () => ({str: 'foo'}), - written: clientID => ({clientID, id: '', str: 'foo'}), + name: 'set with clientID and id', + collectionName: 'entryNoID', + value: {clientID, id: 'a', str: 'foo'}, + expectError: { + _errors: ["Unrecognized key(s) in object: 'id'"], + }, }, { - name: 'null', - id, - preexisting: false, - input: () => null, - expectError: () => 'TypeError: Expected object, received null', + name: 'set with clientID and id', + collectionName: 'entryID', + value: {clientID, id: 'a', str: 'foo'}, + expectedValue: {clientID, id: 'a', str: 'foo'}, + expectedKey: '-/p/$CLIENT_ID/entryID/id/a', }, { - name: 'undefined', - id, - preexisting: false, - input: () => undefined, - expectError: () => 'TypeError: Expected object, received undefined', + name: 'set with implicit clientID (no id)', + collectionName: 'entryNoID', + value: {str: 'foo'}, + expectedValue: {clientID, str: 'foo'}, + expectedKey: `-/p/${clientID}/entryNoID/`, }, { - name: 'string', - id, - preexisting: false, - input: () => 'foo', - expectError: () => 'TypeError: Expected object, received string', + name: 'set with implicit clientID (no id)', + collectionName: 'entryID', + value: {str: 'foo'}, + expectError: {_errors: [], id: {_errors: ['Required']}}, }, + + ...sameForBoth({ + name: 'set to null', + value: null, + expectError: 'TypeError: Expected object, received null', + }), + + ...sameForBoth({ + name: 'set to undefined', + value: undefined, + expectError: 'TypeError: Expected object, received undefined', + }), + + ...sameForBoth({ + name: 'set to string', + value: 'junk', + expectError: 'TypeError: Expected object, received string', + }), + { - name: 'no-str', - id, - preexisting: false, - input: clientID => ({clientID, id}), - expectError: () => ({_errors: [], str: {_errors: ['Required']}}), + name: 'set to value missing str', + collectionName: 'entryNoID', + value: {}, + expectError: {_errors: [], str: {_errors: ['Required']}}, }, { - name: 'valid', - id, - preexisting: false, - input: clientID => ({clientID, id, str: 'foo'}), + name: 'set to value missing str', + collectionName: 'entryID', + value: {id: 'c'}, + expectError: {_errors: [], str: {_errors: ['Required']}}, }, + { - name: 'with-opt-filed', - id, - preexisting: false, - input: clientID => ({clientID, id, str: 'foo', optStr: 'bar'}), + name: 'set with optStr', + collectionName: 'entryNoID', + value: {str: 'foo', optStr: 'bar'}, + expectedValue: {clientID, str: 'foo', optStr: 'bar'}, + expectedKey: `-/p/${clientID}/entryNoID/`, }, { - name: 'preexisting', - id, - preexisting: true, - input: clientID => ({clientID, id, str: 'foo'}), + name: 'set with optStr', + collectionName: 'entryID', + value: {str: 'foo', id, optStr: 'bar'}, + expectedValue: {clientID, id, str: 'foo', optStr: 'bar'}, + expectedKey: `-/p/${clientID}/entryID/id/${id}`, }, + { name: 'setting with wrong clientID', - id, - preexisting: false, - input: () => ({clientID: 'wrong', id, str: 'foo'}), - expectError: clientID => - `Error: Can only mutate own entities. Expected clientID "${clientID}" but received "wrong"`, + collectionName: 'entryNoID', + value: {clientID: 'wrong', str: 'foo'}, + expectError: `Error: Can only mutate own entities. Expected clientID "${clientID}" but received "wrong"`, + }, + { + name: 'setting with wrong clientID', + collectionName: 'entryID', + value: {clientID: 'wrong', id: 'v', str: 'foo'}, + expectError: `Error: Can only mutate own entities. Expected clientID "${clientID}" but received "wrong"`, }, ]; for (const f of factories) { for (const c of cases) { - test(c.name, async () => { - const {written = c.input, id} = c; + test(`${c.name} using ${c.collectionName}`, async () => { + const {expectedValue, collectionName} = c; const r = f(mutators); const clientID = await r.clientID; - if (c.preexisting) { - await r.mutate.setE1({clientID, id, str: 'preexisting'}); - } - + const replace = (v: V): V => + replaceClientID(v, '$CLIENT_ID', clientID) as V; let error = undefined; try { - await r.mutate.setE1(c.input(clientID) as E1); + if (collectionName === 'entryID') { + await r.mutate.setEntryID(replace(c.value) as EntryID); + } else { + await r.mutate.setEntryNoID(replace(c.value) as EntryNoID); + } } catch (e) { if (e instanceof ZodError) { error = e.format(); @@ -180,16 +254,12 @@ suite('set', () => { } } - const key = `-/p/${clientID}/e1/${id}`; - - const actual = await r.query(tx => tx.get(key)); - - if (c.expectError !== undefined) { - expect(error).deep.equal(c.expectError?.(clientID)); - expect(actual).undefined; + if (c.expectError) { + expect(error).toEqual(replace(c.expectError)); } else { - expect(error).undefined; - expect(actual).deep.equal(written(clientID)); + const key = replaceClientID(c.expectedKey!, '$CLIENT_ID', clientID); + const actual = await r.query(tx => tx.get(key)); + expect(actual).toEqual(replace(expectedValue)); } }); } @@ -199,117 +269,177 @@ suite('set', () => { suite('init', () => { type Case = { name: string; - id: string; - preexisting: boolean; - input: (clientID: string) => unknown; - written?: (clientID: string) => unknown; - expectError?: (clientID: string) => unknown; - expectResult?: boolean; + collectionName: 'entryNoID' | 'entryID'; + value: ReadonlyJSONValue | undefined; + expectedKey: string; + expectedValue: ReadonlyJSONValue | undefined; + expectError?: ReadonlyJSONValue; + preexisting?: ReadonlyJSONValue; }; - const id = 'id1'; + const clientID = '$CLIENT_ID'; const cases: Case[] = [ { name: 'null', - id, - preexisting: false, - input: () => null, - expectError: () => 'TypeError: Expected object, received null', + collectionName: 'entryNoID', + value: null, + expectError: 'TypeError: Expected object, received null', + expectedKey: `-/p/${clientID}/entryNoID/`, + expectedValue: undefined, + }, + { + name: 'null', + collectionName: 'entryID', + value: null, + expectError: 'TypeError: Expected object, received null', + expectedKey: `-/p/${clientID}/entryID/id/a`, + expectedValue: undefined, }, { name: 'undefined', - id, - preexisting: false, - input: () => undefined, - expectError: () => 'TypeError: Expected object, received undefined', + collectionName: 'entryNoID', + value: undefined, + expectError: 'TypeError: Expected object, received undefined', + expectedKey: `-/p/${clientID}/entryNoID/`, + expectedValue: undefined, + }, + { + name: 'undefined', + collectionName: 'entryID', + value: undefined, + expectError: 'TypeError: Expected object, received undefined', + expectedKey: `-/p/${clientID}/entryID/id/b`, + expectedValue: undefined, }, { name: 'string', - id, - preexisting: false, - input: () => 'foo', - expectError: () => 'TypeError: Expected object, received string', + collectionName: 'entryNoID', + value: 'junk', + expectError: 'TypeError: Expected object, received string', + expectedKey: `-/p/${clientID}/entryNoID/`, + expectedValue: undefined, }, { - name: 'no-clientID, no-id', - id: '', - preexisting: false, - input: () => ({str: 'foo'}), - written: clientID => ({clientID, id: '', str: 'foo'}), - expectResult: true, + name: 'string', + collectionName: 'entryID', + value: 'junk', + expectError: 'TypeError: Expected object, received string', + expectedKey: `-/p/${clientID}/entryID/id/b`, + expectedValue: undefined, }, { - name: 'no-clientID', - id, - preexisting: false, - input: () => ({id, str: 'foo'}), - written: clientID => ({clientID, id, str: 'foo'}), - expectResult: true, + name: 'init with clientID', + collectionName: 'entryNoID', + value: {clientID, str: 'foo'}, + expectedKey: `-/p/${clientID}/entryNoID/`, + expectedValue: {clientID, str: 'foo'}, + }, + { + name: 'init with clientID and id', + collectionName: 'entryID', + value: {clientID, id: 'a', str: 'foo'}, + expectedKey: `-/p/${clientID}/entryID/id/a`, + expectedValue: {clientID, id: 'a', str: 'foo'}, }, { - name: 'no-id', - id: '', - preexisting: false, - input: clientID => ({clientID, str: 'foo'}), - written: clientID => ({clientID, id: '', str: 'foo'}), - expectResult: true, + name: 'init with clientID with preexisting', + collectionName: 'entryNoID', + preexisting: {clientID, str: 'before'}, + value: {clientID, str: 'foo'}, + expectedKey: `-/p/${clientID}/entryNoID/`, + expectedValue: {clientID, str: 'before'}, }, { - name: 'no-str', - id, - preexisting: false, - input: clientID => ({clientID, id}), - expectError: () => ({_errors: [], str: {_errors: ['Required']}}), + name: 'init with clientID and id with preexisting', + collectionName: 'entryID', + preexisting: {clientID, id: 'a', str: 'before'}, + value: {clientID, id: 'a', str: 'foo'}, + expectedKey: `-/p/${clientID}/entryID/id/a`, + expectedValue: {clientID, id: 'a', str: 'before'}, }, + { - name: 'valid', - id, - preexisting: false, - input: clientID => ({clientID, id, str: 'foo'}), - expectResult: true, + name: 'no str', + collectionName: 'entryNoID', + value: {clientID}, + expectedKey: `-/p/${clientID}/entryNoID/`, + expectedValue: undefined, + expectError: {_errors: [], str: {_errors: ['Required']}}, }, { - name: 'with-opt-filed', - id, - preexisting: false, - input: clientID => ({clientID, id, str: 'foo', optStr: 'bar'}), - expectResult: true, + name: 'no str', + collectionName: 'entryID', + value: {clientID, id: 'c'}, + expectedKey: `-/p/${clientID}/entryID/id/c`, + expectedValue: undefined, + expectError: {_errors: [], str: {_errors: ['Required']}}, }, + { - name: 'preexisting', - id, - preexisting: true, - input: clientID => ({clientID, id, str: 'foo'}), - expectResult: false, + name: 'with optStr', + collectionName: 'entryNoID', + value: {clientID, str: 'x', optStr: 'y'}, + expectedKey: `-/p/${clientID}/entryNoID/`, + expectedValue: {clientID, str: 'x', optStr: 'y'}, }, { - name: 'setting with wrong clientID', - id, - preexisting: false, - input: () => ({clientID: 'wrong', id, str: 'foo'}), - expectError: clientID => - `Error: Can only mutate own entities. Expected clientID "${clientID}" but received "wrong"`, + name: 'with optStr', + collectionName: 'entryID', + value: {clientID, id: 'z', str: 'x', optStr: 'y'}, + expectedKey: `-/p/${clientID}/entryID/id/z`, + expectedValue: {clientID, id: 'z', str: 'x', optStr: 'y'}, + }, + + { + name: 'with wrong clientID', + collectionName: 'entryNoID', + value: {clientID: 'wrong', str: 'x'}, + expectedKey: `-/p/${clientID}/entryNoID/`, + expectedValue: undefined, + expectError: `Error: Can only mutate own entities. Expected clientID "${clientID}" but received "wrong"`, + }, + { + name: 'with wrong clientID', + collectionName: 'entryID', + value: {clientID: 'wrong', id: 'z', str: 'x'}, + expectedKey: `-/p/${clientID}/entryID/id/z`, + expectedValue: undefined, + expectError: `Error: Can only mutate own entities. Expected clientID "${clientID}" but received "wrong"`, }, ]; for (const f of factories) { for (const c of cases) { test(c.name, async () => { - const {id, input, written = input} = c; const r = f(mutators); const clientID = await r.clientID; - - const preexisting = {clientID, id, str: 'preexisting'}; - if (c.preexisting) { - await r.mutate.setE1(preexisting); + const { + expectError, + expectedKey, + expectedValue, + collectionName, + preexisting, + value, + } = replaceClientID(c, '$CLIENT_ID', clientID); + + if (preexisting) { + if (collectionName === 'entryID') { + await r.mutate.setEntryID(preexisting as EntryID); + } else { + await r.mutate.setEntryNoID(preexisting as EntryNoID); + } } - let error = undefined; - let result = undefined; + let error; + let result; try { - result = await r.mutate.initE1(input(clientID) as E1); + if (collectionName === 'entryID') { + result = await r.mutate.initEntryID(value as EntryID); + } else { + result = await r.mutate.initEntryNoID(value as EntryNoID); + } } catch (e) { if (e instanceof ZodError) { error = e.format(); @@ -318,153 +448,251 @@ suite('init', () => { } } - const actual = await r.query(tx => tx.get(`-/p/${clientID}/e1/${id}`)); - if (c.expectError !== undefined) { - expect(error).deep.equal(c.expectError(clientID)); - expect(actual).undefined; + const key = expectedKey; + const actual = await r.query(tx => tx.get(key)); + + if (expectError) { + expect(error).toEqual(expectError); + expect(actual).toEqual(preexisting); expect(result).undefined; } else { expect(error).undefined; - expect(actual).deep.equal( - c.preexisting ? preexisting : written(clientID), - ); - expect(result).eq(c.expectResult); + expect(actual).toEqual(expectedValue); } }); } } }); +function replaceClientID( + v: V, + from: string, + to: string, +): V { + switch (typeof v) { + case 'string': + return v.replaceAll(from, to) as V; + case 'object': + if (v === null) { + return v; + } + if (Array.isArray(v)) { + return v.map(v => replaceClientID(v, from, to)) as unknown as V; + } + return Object.fromEntries( + Object.entries(v).map(([k, v]) => [ + replaceClientID(k, from, to), + replaceClientID(v, from, to), + ]), + ) as V; + default: + return v; + } +} + suite('get', () => { type Case = { name: string; - stored: ((clientID: string) => unknown) | undefined; - id: string; - lookupID?: ( - clientID: string, - ) => Partial<{clientID: string; id: string}> | undefined; - expectError?: ReadonlyJSONObject; + collectionName: 'entryNoID' | 'entryID'; + stored?: ReadonlyJSONValue | undefined; + storedKey?: string; + param: ReadonlyJSONValue | undefined; + expectedValue?: ReadonlyJSONValue | undefined; + expectedError?: ReadonlyJSONValue; }; - const id = 'id1'; + const clientID = '$CLIENT_ID'; + const id = 'b'; const cases: Case[] = [ { - name: 'null', - id, - stored: () => null, - expectError: {_errors: ['Expected object, received null']}, + name: 'Get with clientID, existing', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: {clientID}, }, { - name: 'undefined', - id, + name: 'Get with clientID and id, existing', + collectionName: 'entryID', + stored: {clientID, id: 'a', str: 'foo'}, + param: {clientID, id: 'a'}, + }, + + { + name: 'Get with clientID, non existing', + collectionName: 'entryNoID', stored: undefined, + param: {clientID}, }, { - name: 'string', - id, - stored: () => 'foo', - expectError: {_errors: ['Expected object, received string']}, + name: 'Get with clientID and id, non existing', + collectionName: 'entryID', + stored: undefined, + param: {clientID, id: 'a'}, }, + { - name: 'no-clientID, no-id in stored', - id, - stored: () => ({str: 'foo'}), - expectError: { - _errors: [], - clientID: {_errors: ['Required']}, - id: {_errors: ['Required']}, - }, + name: 'Get with implicit clientID, existing', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: {}, }, { - name: 'no-clientID in stored', - id, - stored: () => ({id, str: 'foo'}), - expectError: {_errors: [], clientID: {_errors: ['Required']}}, + name: 'Get with implicit clientID and id, existing', + collectionName: 'entryID', + stored: {clientID, id: 'a', str: 'foo'}, + param: {id: 'a'}, }, { - name: 'no-id in stored', - id, - stored: clientID => ({clientID, str: 'foo'}), - expectError: {_errors: [], id: {_errors: ['Required']}}, + name: 'Get with no param, existing', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: undefined, }, { - name: 'no-clientID, no-id in lookup', - id: '', - stored: clientID => ({clientID, id: '', str: 'foo'}), - lookupID: () => ({}), + name: 'Get with no param when id is required, existing', + collectionName: 'entryNoID', + stored: {clientID, id: 'a', str: 'foo'}, + param: undefined, + expectedValue: undefined, }, + { - name: 'undefined in lookup', - id: '', - stored: clientID => ({clientID, id: '', str: 'foo'}), - lookupID: () => undefined, + name: 'Stored value is incorrect (extra id)', + collectionName: 'entryNoID', + stored: {clientID, id: 'a', str: 'foo'}, + storedKey: `-/p/${clientID}/entryNoID/`, + param: {clientID}, + expectedError: { + _errors: ["Unrecognized key(s) in object: 'id'"], + }, + expectedValue: undefined, }, { - name: 'no-clientID in lookup', - id, - stored: clientID => ({clientID, id, str: 'foo'}), - lookupID: () => ({id}), + name: 'Stored value is incorrect (missing id)', + collectionName: 'entryID', + stored: {clientID, str: 'foo'}, + storedKey: '-/p/$CLIENT_ID/entryID/id/a', + param: {clientID, id: 'a'}, + expectedError: { + _errors: [], + id: {_errors: ['Required']}, + }, + expectedValue: undefined, }, { - name: 'no-id in lookup', - id: '', - stored: clientID => ({clientID, id: '', str: 'foo'}), - lookupID: clientID => ({clientID}), + name: 'Stored value is incorrect (missing clientID)', + collectionName: 'entryNoID', + stored: {str: 'foo'}, + storedKey: `-/p/${clientID}/entryNoID/`, + param: {clientID}, + expectedError: { + _errors: [], + clientID: {_errors: ['Required']}, + }, + expectedValue: undefined, }, { - name: 'no-str', - id, - stored: clientID => ({clientID, id}), - expectError: {_errors: [], str: {_errors: ['Required']}}, + name: 'Stored value is incorrect (missing id and clientID)', + collectionName: 'entryID', + stored: {str: 'foo'}, + storedKey: '-/p/$CLIENT_ID/entryID/id/a', + param: {clientID, id: 'a'}, + expectedError: { + _errors: [], + clientID: {_errors: ['Required']}, + id: {_errors: ['Required']}, + }, + expectedValue: undefined, }, { - name: 'valid', - id, - stored: clientID => ({clientID, id, str: 'foo'}), + name: 'Stored value is incorrect (missing str)', + collectionName: 'entryNoID', + stored: {clientID}, + storedKey: `-/p/${clientID}/entryNoID/`, + param: {clientID}, + expectedError: { + _errors: [], + str: {_errors: ['Required']}, + }, + expectedValue: undefined, }, { - name: 'with-opt-filed', - id, - stored: clientID => ({clientID, id, str: 'foo', optStr: 'bar'}), + name: 'Stored value is incorrect (wrong optStr)', + collectionName: 'entryID', + stored: {clientID, id, str: 'a', optStr: true}, + storedKey: `-/p/${clientID}/entryID/id/${id}`, + param: {clientID, id}, + expectedError: { + _errors: [], + optStr: {_errors: ['Expected string, received boolean']}, + }, + expectedValue: undefined, + }, + { + name: 'Stored value is incorrect (null)', + collectionName: 'entryNoID', + stored: null, + storedKey: `-/p/${clientID}/entryNoID/`, + param: {clientID}, + expectedError: { + _errors: ['Expected object, received null'], + }, + expectedValue: undefined, + }, + { + name: 'Stored value is incorrect (string)', + collectionName: 'entryID', + stored: 'junk', + storedKey: '-/p/$CLIENT_ID/entryID/id/a', + param: {clientID, id: 'a'}, + expectedError: { + _errors: ['Expected object, received string'], + }, + expectedValue: undefined, }, ]; for (const f of factories) { for (const c of cases) { - test(c.name, async () => { + test(`${c.name} using ${c.collectionName}`, async () => { const r = f(mutators); const clientID = await r.clientID; - const {id, lookupID = (clientID: string) => ({clientID, id})} = c; - if (c.stored !== undefined) { + const fixedC = replaceClientID(c, '$CLIENT_ID', clientID); + const { + collectionName, + stored, + storedKey = stored + ? keyFromID(collectionName, stored as PresenceEntity) + : undefined, + param, + expectedError, + } = fixedC; + const expectedValue = + 'expectedValue' in fixedC ? fixedC.expectedValue : stored; + + if (stored !== undefined && storedKey !== undefined) { await r.mutate.directWrite({ - key: `-/p/${clientID}/e1/${id}`, - val: c.stored(clientID) as E1, + key: storedKey, + val: stored, }); } - const {actual, error} = await r.query( - async ( - tx, - ): Promise< - | { - actual: E1 | undefined; - error?: undefined; - } - | {error: {_errors: string[]}; actual?: undefined} - > => { - try { - return {actual: await getE1(tx, lookupID(clientID))}; - } catch (e) { - return {error: (e as ZodError).format()}; - } - }, - ); - expect(error).deep.equal(c.expectError, c.name); - expect(actual).deep.equal( - c.expectError ? undefined : c.stored?.(clientID), - c.name, - ); + const {actual, error} = await r.query(async tx => { + try { + return { + actual: + collectionName === 'entryNoID' + ? await getEntryNoID(tx, param as EntryNoID) + : await getEntryID(tx, param as EntryID), + }; + } catch (e) { + return {error: (e as ZodError).format()}; + } + }); + + expect(error).toEqual(expectedError); + expect(actual).toEqual(expectedValue); }); } } @@ -473,111 +701,214 @@ suite('get', () => { suite('mustGet', () => { type Case = { name: string; - id: string; - stored: ((clientID: string) => unknown) | undefined; - lookupID?: ( - clientID: string, - ) => Partial<{clientID: string; id: string}> | undefined; - expectError?: (clientID: string) => unknown; + collectionName: 'entryNoID' | 'entryID'; + stored?: ReadonlyJSONValue | undefined; + storedKey?: string; + param: ReadonlyJSONValue | undefined; + expectedValue?: ReadonlyJSONValue | undefined; + expectedError?: ReadonlyJSONValue; }; - const id = 'id1'; + const clientID = '$CLIENT_ID'; + const id = 'b'; const cases: Case[] = [ { - name: 'null', - id, - stored: () => null, - expectError: () => ({_errors: ['Expected object, received null']}), + name: 'Get with clientID, existing', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: {clientID}, }, { - name: 'undefined', - id, + name: 'Get with clientID and id, existing', + collectionName: 'entryID', + stored: {clientID, id: 'a', str: 'foo'}, + param: {clientID, id: 'a'}, + }, + + { + name: 'Get with clientID, non existing', + collectionName: 'entryNoID', stored: undefined, - expectError: clientID => - `Error: no such entity {"clientID":"${clientID}","id":"${id}"}`, + param: {clientID}, + expectedError: `Error: no such entity {"clientID":"${clientID}"}`, + expectedValue: undefined, }, { - name: 'valid', - id, - stored: clientID => ({clientID, id, str: 'foo'}), + name: 'Get with clientID and id, non existing', + collectionName: 'entryID', + stored: undefined, + param: {clientID, id: 'a'}, + expectedError: `Error: no such entity {"clientID":"${clientID}","id":"a"}`, + expectedValue: undefined, + }, + + { + name: 'Get with implicit clientID, existing', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: {}, + }, + { + name: 'Get with implicit clientID and id, existing', + collectionName: 'entryID', + stored: {clientID, id: 'a', str: 'foo'}, + param: {id: 'a'}, }, { - name: 'no-clientID, no-id in stored', - id, - stored: () => ({str: 'foo'}), - expectError: () => ({ + name: 'Get with no param, existing', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: undefined, + }, + { + name: 'Get with no param when id is required, existing', + collectionName: 'entryNoID', + stored: {clientID, id: 'a', str: 'foo'}, + param: undefined, + expectedError: `Error: no such entity {"clientID":"${clientID}"}`, + expectedValue: undefined, + }, + + { + name: 'Stored value is incorrect (extra id)', + collectionName: 'entryNoID', + stored: {clientID, id: 'a', str: 'foo'}, + storedKey: `-/p/${clientID}/entryNoID/`, + param: {clientID}, + expectedError: { + _errors: ["Unrecognized key(s) in object: 'id'"], + }, + expectedValue: undefined, + }, + { + name: 'Stored value is incorrect (missing id)', + collectionName: 'entryID', + stored: {clientID, str: 'foo'}, + storedKey: '-/p/$CLIENT_ID/entryID/id/a', + param: {clientID, id: 'a'}, + expectedError: { _errors: [], - clientID: {_errors: ['Required']}, id: {_errors: ['Required']}, - }), + }, + expectedValue: undefined, }, { - name: 'no-clientID in stored', - id, - stored: () => ({id, str: 'foo'}), - expectError: () => ({_errors: [], clientID: {_errors: ['Required']}}), + name: 'Stored value is incorrect (missing clientID)', + collectionName: 'entryNoID', + stored: {str: 'foo'}, + storedKey: `-/p/${clientID}/entryNoID/`, + param: {clientID}, + expectedError: { + _errors: [], + clientID: {_errors: ['Required']}, + }, + expectedValue: undefined, }, { - name: 'no-id in stored', - id, - stored: clientID => ({clientID, str: 'foo'}), - expectError: () => ({_errors: [], id: {_errors: ['Required']}}), + name: 'Stored value is incorrect (missing id and clientID)', + collectionName: 'entryID', + stored: {str: 'foo'}, + storedKey: '-/p/$CLIENT_ID/entryID/id/a', + param: {clientID, id: 'a'}, + expectedError: { + _errors: [], + clientID: {_errors: ['Required']}, + id: {_errors: ['Required']}, + }, + expectedValue: undefined, }, { - name: 'no-clientID, no-id in lookup', - id: '', - stored: clientID => ({clientID, id: '', str: 'foo'}), - lookupID: () => ({}), + name: 'Stored value is incorrect (missing str)', + collectionName: 'entryNoID', + stored: {clientID}, + storedKey: `-/p/${clientID}/entryNoID/`, + param: {clientID}, + expectedError: { + _errors: [], + str: {_errors: ['Required']}, + }, + expectedValue: undefined, }, { - name: 'undefined in lookup', - id: '', - stored: clientID => ({clientID, id: '', str: 'foo'}), - lookupID: () => undefined, + name: 'Stored value is incorrect (wrong optStr)', + collectionName: 'entryID', + stored: {clientID, id, str: 'a', optStr: true}, + storedKey: `-/p/${clientID}/entryID/id/${id}`, + param: {clientID, id}, + expectedError: { + _errors: [], + optStr: {_errors: ['Expected string, received boolean']}, + }, + expectedValue: undefined, }, { - name: 'no-clientID in lookup', - id, - stored: clientID => ({clientID, id, str: 'foo'}), - lookupID: () => ({id}), + name: 'Stored value is incorrect (null)', + collectionName: 'entryNoID', + stored: null, + storedKey: `-/p/${clientID}/entryNoID/`, + param: {clientID}, + expectedError: { + _errors: ['Expected object, received null'], + }, + expectedValue: undefined, }, { - name: 'no-id in lookup', - id: '', - stored: clientID => ({clientID, id: '', str: 'foo'}), - lookupID: clientID => ({clientID}), + name: 'Stored value is incorrect (string)', + collectionName: 'entryID', + stored: 'junk', + storedKey: '-/p/$CLIENT_ID/entryID/id/a', + param: {clientID, id: 'a'}, + expectedError: { + _errors: ['Expected object, received string'], + }, + expectedValue: undefined, }, ]; for (const f of factories) { for (const c of cases) { - test(c.name, async () => { + test(`${c.name} using ${c.collectionName}`, async () => { const r = f(mutators); - const {id, lookupID = (clientID: string) => ({clientID, id})} = c; const clientID = await r.clientID; - if (c.stored !== undefined) { + const fixedC = replaceClientID(c, '$CLIENT_ID', clientID); + const { + collectionName, + stored, + storedKey = stored + ? keyFromID(collectionName, stored as PresenceEntity) + : undefined, + param, + expectedError, + } = fixedC; + const expectedValue = + 'expectedValue' in fixedC ? fixedC.expectedValue : stored; + + if (stored !== undefined && storedKey !== undefined) { await r.mutate.directWrite({ - key: `-/p/${clientID}/e1/${id}`, - val: c.stored(clientID) as E1, + key: storedKey, + val: stored, }); } const {actual, error} = await r.query(async tx => { try { - return {actual: await mustGetE1(tx, lookupID(clientID))}; + return { + actual: + collectionName === 'entryNoID' + ? await mustGetEntryNoID(tx, param as EntryNoID) + : await mustGetEntryID(tx, param as EntryID), + }; } catch (e) { if (e instanceof ZodError) { - return {error: (e as ZodError).format()}; + return {error: e.format()}; } return {error: String(e)}; } }); - expect(error).deep.equal(c.expectError?.(clientID), c.name); - expect(actual).deep.equal( - c.expectError ? undefined : c.stored?.(clientID), - c.name, - ); + + expect(error).toEqual(expectedError); + expect(actual).toEqual(expectedValue); }); } } @@ -586,80 +917,105 @@ suite('mustGet', () => { suite('has', () => { type Case = { name: string; - id: string; - lookupID?: ( - clientID: string, - ) => Partial<{clientID: string; id: string}> | undefined; - stored: ((clientID: string) => unknown) | undefined; - expectHas: boolean; + collectionName: 'entryNoID' | 'entryID'; + stored?: ReadonlyJSONValue | undefined; + storedKey?: string; + param: ReadonlyJSONValue | undefined; + expectedHas: boolean; }; - const id = 'id1'; + const clientID = '$CLIENT_ID'; + const id = 'b'; const cases: Case[] = [ { name: 'undefined', - id, + collectionName: 'entryNoID', stored: undefined, - expectHas: false, + param: {clientID}, + expectedHas: false, }, { name: 'null', - id, - stored: () => null, - expectHas: true, + collectionName: 'entryNoID', + stored: null, + storedKey: `-/p/${clientID}/entryNoID/`, + param: {clientID}, + expectedHas: true, }, { name: 'string', - id, - stored: () => 'foo', - expectHas: true, + collectionName: 'entryID', + stored: 'junk', + storedKey: `-/p/${clientID}/entryID/id/${id}`, + param: {clientID, id}, + expectedHas: true, + }, + { + name: 'valid', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: {clientID}, + expectedHas: true, }, { - name: 'no-clientID, no-id in lookup', - id: '', - stored: clientID => ({clientID, id: '', str: 'foo'}), - lookupID: () => ({}), - expectHas: true, + name: 'valid', + collectionName: 'entryID', + stored: {clientID, id, str: 'foo'}, + param: {clientID, id}, + expectedHas: true, }, { - name: 'undefined in lookup', - id: '', - stored: clientID => ({clientID, id: '', str: 'foo'}), - lookupID: () => undefined, - expectHas: true, + name: 'valid implicit clientID', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: {}, + expectedHas: true, }, { - name: 'no-clientID in lookup', - id, - stored: clientID => ({clientID, id, str: 'foo'}), - lookupID: () => ({id}), - expectHas: true, + name: 'valid implicit clientID', + collectionName: 'entryID', + stored: {clientID, id, str: 'foo'}, + param: {id}, + expectedHas: true, }, { - name: 'no-id in lookup', - id: '', - stored: clientID => ({clientID, id: '', str: 'foo'}), - lookupID: clientID => ({clientID}), - expectHas: true, + name: 'valid no param', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: undefined, + expectedHas: true, }, ]; for (const f of factories) { for (const c of cases) { - test(c.name, async () => { + test(`${c.name} using ${c.collectionName}`, async () => { const r = f(mutators); const clientID = await r.clientID; - const {id, lookupID = (clientID: string) => ({clientID, id})} = c; - - if (c.stored !== undefined) { + const { + collectionName, + stored, + storedKey = stored + ? keyFromID(collectionName, stored as PresenceEntity) + : undefined, + param, + expectedHas, + }: Case = replaceClientID(c, '$CLIENT_ID', clientID); + + if (stored !== undefined && storedKey) { await r.mutate.directWrite({ - key: `-/p/${clientID}/e1/${id}`, - val: c.stored(clientID) as E1, + key: storedKey, + val: stored, }); } - const has = await r.query(tx => hasE1(tx, lookupID(clientID))); - expect(has).eq(c.expectHas, c.name); + const has = await r.query(tx => { + if (collectionName === 'entryNoID') { + return hasEntryNoID(tx, param as EntryNoID); + } + return hasEntryID(tx, param as EntryID); + }); + expect(has).toBe(expectedHas); }); } } @@ -668,114 +1024,204 @@ suite('has', () => { suite('update', () => { type Case = { name: string; - id: string; - prev?: (clientID: string) => unknown; - update: (clientID: string) => ReadonlyJSONObject; - expected?: (clientID: string) => unknown; - expectError?: (clientID: string) => unknown; + collectionName: 'entryNoID' | 'entryID'; + stored?: ReadonlyJSONValue | undefined; + storedKey?: string; + param: ReadonlyJSONValue | undefined; + expectedValue: ReadonlyJSONValue | undefined; + expectedKey?: string; + expectedError?: ReadonlyJSONValue; }; - const id = 'id1'; + const clientID = '$CLIENT_ID'; + const id = 'b'; const cases: Case[] = [ { - name: 'prev-invalid', - id, - prev: () => null, - update: () => ({}), - expectError: () => ({_errors: ['Expected object, received null']}), + name: 'stored value invalid', + collectionName: 'entryNoID', + stored: null, + storedKey: `-/p/${clientID}/entryNoID/`, + param: {}, + expectedValue: null, + expectedKey: `-/p/${clientID}/entryNoID/`, + expectedError: {_errors: ['Expected object, received null']}, }, { - name: 'not-existing-update-clientID', - id, - prev: clientID => ({clientID, id, str: 'foo', optStr: 'bar'}), - update: clientID => ({clientID, id, str: 'baz'}), - expected: clientID => ({clientID, id, str: 'baz', optStr: 'bar'}), + name: 'stored value invalid', + collectionName: 'entryID', + stored: 'joke', + storedKey: `-/p/${clientID}/entryID/id/${id}`, + param: {id}, + expectedValue: 'joke', + expectedKey: `-/p/${clientID}/entryID/id/${id}`, + expectedError: {_errors: ['Expected object, received string']}, }, { - name: "not-existing-update-clientID different id doesn't change old", - id: 'a', - prev: clientID => ({clientID, id: 'a', str: 'foo', optStr: 'bar'}), - update: clientID => ({clientID, id: 'b', str: 'baz'}), - expected: clientID => ({clientID, id: 'a', str: 'foo', optStr: 'bar'}), + name: 'stored value invalid', + collectionName: 'entryNoID', + stored: {clientID, id: 'should not have id', str: 'foo'}, + storedKey: `-/p/${clientID}/entryNoID/`, + param: {clientID, str: 'bar'}, + expectedValue: {clientID, id: 'should not have id', str: 'foo'}, + expectedKey: `-/p/${clientID}/entryNoID/`, + expectedError: {_errors: ["Unrecognized key(s) in object: 'id'"]}, }, { - name: 'not-existing-update-clientID different id sets new', - id: 'b', - prev: clientID => ({clientID, id: 'a', str: 'foo', optStr: 'bar'}), - update: clientID => ({clientID, id: 'b', str: 'baz'}), - expected: clientID => ({clientID, id: 'b', str: 'baz', optStr: 'bar'}), + name: 'stored value invalid', + collectionName: 'entryID', + stored: {clientID, str: 'missing id'}, + storedKey: `-/p/${clientID}/entryID/id/${id}`, + param: {clientID, id, str: 'bar'}, + expectedValue: {clientID, str: 'missing id'}, + expectedKey: `-/p/${clientID}/entryID/id/${id}`, + expectedError: {_errors: [], id: {_errors: ['Required']}}, }, { - name: 'not-existing-update no clientID, no id', - id: '', - prev: clientID => ({clientID, id: '', str: 'foo', optStr: 'bar'}), - update: () => ({str: 'baz'}), - expected: clientID => ({clientID, id: '', str: 'baz', optStr: 'bar'}), + name: 'no previous value', + collectionName: 'entryID', + param: {clientID, str: 'foo'}, + expectedValue: undefined, + expectedKey: `-/p/${clientID}/entryID/`, }, { - name: 'not-existing-update no clientID', - id, - prev: clientID => ({clientID, id, str: 'foo', optStr: 'bar'}), - update: () => ({id, str: 'baz'}), - expected: clientID => ({clientID, id, str: 'baz', optStr: 'bar'}), + name: 'no previous value', + collectionName: 'entryID', + param: {clientID, str: 'foo', id}, + expectedValue: undefined, + expectedKey: `-/p/${clientID}/entryID/id/${id}`, }, { - name: 'not-existing-update no id', - id: '', - prev: clientID => ({clientID, id: '', str: 'foo', optStr: 'bar'}), - update: clientID => ({clientID, str: 'baz'}), - expected: clientID => ({clientID, id: '', str: 'baz', optStr: 'bar'}), + name: 'valid', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: {clientID, str: 'bar'}, + expectedValue: {clientID, str: 'bar'}, }, { - name: 'invalid-update', - id, - prev: clientID => ({clientID, id, str: 'foo', optStr: 'bar'}), - update: clientID => ({clientID, id, str: 42}), - expected: clientID => ({clientID, id, str: 'foo', optStr: 'bar'}), - expectError: () => ({ - _errors: [], - str: {_errors: ['Expected string, received number']}, - }), + name: 'valid', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: {clientID, optStr: 'opt'}, + expectedValue: {clientID, str: 'foo', optStr: 'opt'}, + }, + { + name: 'valid', + collectionName: 'entryID', + stored: {clientID, id, str: 'foo'}, + param: {clientID, id, str: 'bar'}, + expectedValue: {clientID, id, str: 'bar'}, + }, + { + name: 'valid', + collectionName: 'entryID', + stored: {clientID, id, str: 'foo'}, + param: {clientID, id, optStr: 'opt'}, + expectedValue: {clientID, id, str: 'foo', optStr: 'opt'}, + }, + + { + name: 'valid implicit clientID', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: {str: 'bar'}, + expectedValue: {clientID, str: 'bar'}, + }, + { + name: 'valid implicit clientID', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: {optStr: 'opt'}, + expectedValue: {clientID, str: 'foo', optStr: 'opt'}, + }, + { + name: 'valid implicit clientID', + collectionName: 'entryID', + stored: {clientID, id, str: 'foo'}, + param: {id, str: 'bar'}, + expectedValue: {clientID, id, str: 'bar'}, }, { - name: 'valid-update', - id, - prev: clientID => ({clientID, id, str: 'foo', optStr: 'bar'}), - update: clientID => ({clientID, id, str: 'baz'}), - expected: clientID => ({clientID, id, str: 'baz', optStr: 'bar'}), - expectError: undefined, + name: 'valid implicit clientID', + collectionName: 'entryID', + stored: {clientID, id, str: 'foo'}, + param: {id, optStr: 'opt'}, + expectedValue: {clientID, id, str: 'foo', optStr: 'opt'}, + }, + + { + name: 'invalid update has wrong shape', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: {clientID, str: 'bar', extra: true}, + expectedValue: {clientID, str: 'foo'}, + expectedError: {_errors: ["Unrecognized key(s) in object: 'extra'"]}, + }, + { + name: 'invalid update has wrong type', + collectionName: 'entryID', + stored: {clientID, id, str: 'foo'}, + param: {clientID, id, str: false}, + expectedValue: {clientID, id, str: 'foo'}, + expectedError: { + _errors: [], + str: {_errors: ['Expected string, received boolean']}, + }, }, { name: 'update with wrong clientID', - id, - prev: clientID => ({clientID, id, str: 'foo', optStr: 'bar'}), - update: () => ({clientID: 'wrong', id, str: 'baz'}), - expectError: clientID => - `Error: Can only mutate own entities. Expected clientID "${clientID}" but received "wrong"`, + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: {clientID: 'wrong', str: 'bar'}, + expectedValue: {clientID, str: 'foo'}, + expectedError: `Error: Can only mutate own entities. Expected clientID "${clientID}" but received "wrong"`, + }, + { + name: 'update with wrong clientID', + collectionName: 'entryID', + stored: {clientID, id, str: 'foo'}, + param: {clientID: 'wrong', id, str: 'bar'}, + expectedValue: {clientID, id, str: 'foo'}, + expectedError: `Error: Can only mutate own entities. Expected clientID "${clientID}" but received "wrong"`, }, ]; for (const f of factories) { for (const c of cases) { - test(c.name, async () => { + test(`${c.name} using ${c.collectionName}`, async () => { const r = f(mutators); const clientID = await r.clientID; - const {id} = c; - - if (c.prev !== undefined) { + const { + collectionName, + stored, + storedKey = stored !== undefined + ? keyFromID(collectionName, stored as PresenceEntity) + : undefined, + param, + expectedValue, + expectedKey = keyFromID( + collectionName, + expectedValue as PresenceEntity, + ), + expectedError, + }: Case = replaceClientID(c, '$CLIENT_ID', clientID); + + if (stored !== undefined && storedKey !== undefined) { await r.mutate.directWrite({ - key: `-/p/${clientID}/e1/${id}`, - val: c.prev(clientID) as E1, + key: storedKey, + val: stored, }); } - let error = undefined; - let actual = undefined; + let error; + try { - await r.mutate.updateE1(c.update(clientID) as E1); - actual = await r.query(tx => getE1(tx, {clientID, id})); + if (collectionName === 'entryNoID') { + await r.mutate.updateEntryNoID(param as EntryNoID); + } else { + await r.mutate.updateEntryID(param as EntryID); + } } catch (e) { if (e instanceof ZodError) { error = e.format(); @@ -783,11 +1229,9 @@ suite('update', () => { error = String(e); } } - expect(error).deep.equal(c.expectError?.(clientID), c.name); - expect(actual).deep.equal( - c.expectError ? undefined : c.expected?.(clientID), - c.name, - ); + const actual = await r.query(tx => tx.get(expectedKey)); + expect(error).toEqual(expectedError); + expect(actual).toEqual(expectedValue); }); } } @@ -796,113 +1240,161 @@ suite('update', () => { suite('delete', () => { type Case = { name: string; - id: string; - deleteID?: ( - clientID: string, - ) => Partial<{clientID: string; id: string}> | undefined; - lookupID?: (clientID: string) => Partial<{clientID: string; id: string}>; - stored?: (clientID: string) => unknown; - expectedValue?: (clientID: string) => unknown; - expectError?: (clientID: string) => unknown; + collectionName: 'entryNoID' | 'entryID'; + stored?: ReadonlyJSONValue | undefined; + storedKey?: string; + param: ReadonlyJSONValue | undefined; + expectedValue?: ReadonlyJSONValue | undefined; + expectedKey: string; + expectedError?: ReadonlyJSONValue; }; - const id = 'id1'; + const clientID = '$CLIENT_ID'; + const id = 'b'; const cases: Case[] = [ { - name: 'prev-exist', - id, - stored: clientID => ({clientID, id, str: 'foo', optStr: 'bar'}), + name: 'previous exist', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: {clientID}, + expectedValue: undefined, + expectedKey: `-/p/${clientID}/entryNoID/`, }, { - name: 'prev-exist no-clientID, no-id', - id: '', - stored: clientID => ({clientID, id: '', str: 'foo', optStr: 'bar'}), - deleteID: () => ({}), + name: 'previous exist', + collectionName: 'entryID', + stored: {clientID, id, str: 'foo'}, + param: {clientID, id}, + expectedValue: undefined, + expectedKey: `-/p/${clientID}/entryID/id/${id}`, }, + { - name: 'prev-exist undefined deleteID', - id: '', - stored: clientID => ({clientID, id: '', str: 'foo', optStr: 'bar'}), - deleteID: () => undefined, + name: 'previous exist implicit clientID', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: {}, + expectedValue: undefined, + expectedKey: `-/p/${clientID}/entryNoID/`, }, { - name: 'prev-exist no-clientID', - id, - stored: clientID => ({clientID, id, str: 'foo', optStr: 'bar'}), - deleteID: () => ({id}), + name: 'previous exist implicit clientID', + collectionName: 'entryID', + stored: {clientID, id, str: 'foo'}, + param: {id}, + expectedValue: undefined, + expectedKey: `-/p/${clientID}/entryID/id/${id}`, }, { - name: 'prev-exist no-id', - id: '', - stored: clientID => ({clientID, id: '', str: 'foo', optStr: 'bar'}), - deleteID: clientID => ({clientID}), + name: 'previous exist no param', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: undefined, + expectedValue: undefined, + expectedKey: `-/p/${clientID}/entryNoID/`, }, + { - name: 'prev-not-exist', - id, + name: 'no stored value at key', + collectionName: 'entryNoID', + stored: undefined, + param: {clientID}, + expectedValue: undefined, + expectedKey: `-/p/${clientID}/entryNoID/`, }, { - name: 'prev-exist different id', - id, - stored: clientID => ({clientID, id: 'a', str: 'foo', optStr: 'bar'}), + name: 'no stored value at key', + collectionName: 'entryID', + stored: undefined, + param: {clientID, id}, + expectedValue: undefined, + expectedKey: `-/p/${clientID}/entryNoID/id/${id}`, }, { - name: 'different id', - id: 'a', - stored: clientID => ({clientID, id: 'a', str: 'foo', optStr: 'bar'}), - deleteID: clientID => ({clientID, id: 'b'}), - lookupID: clientID => ({clientID, id: 'a'}), - expectedValue: clientID => ({ - clientID, - id: 'a', - str: 'foo', - optStr: 'bar', - }), + name: 'no stored value at key implicit clientID', + collectionName: 'entryNoID', + stored: undefined, + param: {}, + expectedValue: undefined, + expectedKey: `-/p/${clientID}/entryNoID/`, + }, + { + name: 'no stored value at key implicit clientID', + collectionName: 'entryID', + stored: undefined, + param: {id}, + expectedValue: undefined, + expectedKey: `-/p/${clientID}/entryNoID/id/${id}`, + }, + { + name: 'no stored value at key implicit clientID', + collectionName: 'entryNoID', + stored: undefined, + param: undefined, + expectedValue: undefined, + expectedKey: `-/p/${clientID}/entryNoID/`, }, { - name: 'deleting with wrong clientID', - id, - stored: clientID => ({clientID, id, str: 'foo', optStr: 'bar'}), - deleteID: () => ({clientID: 'wrong', id}), - expectError: clientID => - `Error: Can only mutate own entities. Expected clientID "${clientID}" but received "wrong"`, + name: 'wrong clientID', + collectionName: 'entryNoID', + stored: {clientID, str: 'foo'}, + param: {clientID: 'wrong'}, + expectedValue: {clientID, str: 'foo'}, + expectedKey: `-/p/${clientID}/entryNoID/`, + expectedError: `Error: Can only mutate own entities. Expected clientID "${clientID}" but received "wrong"`, + }, + { + name: 'wrong clientID', + collectionName: 'entryID', + stored: {clientID, id, str: 'foo'}, + param: {clientID: 'wrong', id}, + expectedValue: {clientID, id, str: 'foo'}, + expectedKey: `-/p/${clientID}/entryID/id/${id}`, + expectedError: `Error: Can only mutate own entities. Expected clientID "${clientID}" but received "wrong"`, }, ]; for (const f of factories) { for (const c of cases) { - test(c.name, async () => { + test(`${c.name} using ${c.collectionName}`, async () => { const r = f(mutators); const clientID = await r.clientID; const { - id, - deleteID = (clientID: string) => ({ - clientID, - id, - }), - lookupID = deleteID, - } = c; - - if (c.stored) { + collectionName, + stored, + storedKey = stored !== undefined + ? keyFromID(collectionName, stored as PresenceEntity) + : undefined, + param, + expectedValue, + expectedKey, + expectedError, + }: Case = replaceClientID(c, '$CLIENT_ID', clientID); + + if (stored && storedKey) { await r.mutate.directWrite({ - key: `-/p/${clientID}/e1/${id}`, - val: c.stored(clientID) as E1, + key: storedKey, + val: stored, }); } let error; try { - await r.mutate.deleteE1(deleteID(clientID)); + if (collectionName === 'entryNoID') { + await r.mutate.deleteEntryNoID(param as EntryNoID); + } else { + await r.mutate.deleteEntryID(param as EntryID); + } } catch (e) { error = String(e); } - const actual = await r.query(tx => getE1(tx, lookupID(clientID))); + const actual = await r.query(tx => tx.get(expectedKey)); - expect(actual).deep.equal(c.expectedValue?.(clientID)); - expect(error).deep.equal(c.expectError?.(clientID)); + expect(actual).toEqual(expectedValue); + expect(error).toEqual(expectedError); }); } } @@ -911,102 +1403,212 @@ suite('delete', () => { suite('list', () => { type Case = { name: string; - schema: ZodTypeAny; - options?: ListOptionsForPresence | undefined; - expected?: ReadonlyJSONObject[] | undefined; - expectError?: ReadonlyJSONObject | undefined; + collectionName: 'entryNoID' | 'entryID'; + stored: Record; + param: ListOptions | ListOptions | undefined; + expectedValues: ReadonlyJSONValue[] | undefined; + expectedError?: ReadonlyJSONValue; }; const cases: Case[] = [ { name: 'all', - schema: e1, - expected: [ - {clientID: 'bar', id: '', str: 'bar--str'}, - {clientID: 'bar', id: 'c', str: 'bar-c-str'}, - {clientID: 'baz', id: 'e', str: 'baz-e-str'}, - {clientID: 'baz', id: 'g', str: 'baz-g-str'}, - {clientID: 'foo', id: 'a', str: 'foo-a-str'}, + collectionName: 'entryNoID', + stored: { + '-/p/clientF/entryNoID/': {clientID: 'clientF', str: 'f'}, + '-/p/clientB/entryNoID/': {clientID: 'clientB', str: 'b'}, + '-/p/clientD/entryNoID/': {clientID: 'clientD', str: 'd'}, + }, + param: undefined, + expectedValues: [ + {clientID: 'clientB', str: 'b'}, + {clientID: 'clientD', str: 'd'}, + {clientID: 'clientF', str: 'f'}, + ], + }, + + { + name: 'all', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': {clientID: 'clientB', id: 'b', str: 'b'}, + '-/p/clientD/entryID/id/d': {clientID: 'clientD', id: 'd', str: 'd'}, + }, + param: undefined, + expectedValues: [ + {clientID: 'clientB', id: 'b', str: 'b'}, + {clientID: 'clientD', id: 'd', str: 'd'}, + {clientID: 'clientF', id: 'f', str: 'f'}, ], - expectError: undefined, }, + { - name: 'keystart, clientID', - schema: e1, - options: { - startAtID: {clientID: 'f'}, + name: 'startAtID', + collectionName: 'entryNoID', + stored: { + '-/p/clientF/entryNoID/': {clientID: 'clientF', str: 'f'}, + '-/p/clientB/entryNoID/': {clientID: 'clientB', str: 'b'}, + '-/p/clientD/entryNoID/': {clientID: 'clientD', str: 'd'}, }, - expected: [{clientID: 'foo', id: 'a', str: 'foo-a-str'}], - expectError: undefined, + param: {startAtID: {clientID: 'clientC'}}, + expectedValues: [ + {clientID: 'clientD', str: 'd'}, + {clientID: 'clientF', str: 'f'}, + ], }, { - name: 'keystart, clientID baz', - schema: e1, - options: { - startAtID: {clientID: 'baz'}, + name: 'startAtID', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': {clientID: 'clientB', id: 'b', str: 'b'}, + '-/p/clientD/entryID/id/d': {clientID: 'clientD', id: 'd', str: 'd'}, }, - expected: [ - {clientID: 'baz', id: 'e', str: 'baz-e-str'}, - {clientID: 'baz', id: 'g', str: 'baz-g-str'}, - {clientID: 'foo', id: 'a', str: 'foo-a-str'}, + param: {startAtID: {clientID: 'clientC'}}, + expectedValues: [ + {clientID: 'clientD', id: 'd', str: 'd'}, + {clientID: 'clientF', id: 'f', str: 'f'}, ], - expectError: undefined, }, { - name: 'keystart, clientID and id', - schema: e1, - options: { - startAtID: {clientID: 'baz', id: 'g'}, + name: 'startAtID with clientID and id', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': { + clientID: 'clientB', + id: 'b', + str: 'b', + }, + '-/p/clientD/entryID/id/d111': { + clientID: 'clientD', + id: 'd111', + str: 'd', + }, + '-/p/clientD/entryID/id/d222': { + clientID: 'clientD', + id: 'd222', + str: 'd', + }, + '-/p/clientD/entryID/id/d333': { + clientID: 'clientD', + id: 'd333', + str: 'd', + }, }, - expected: [ - {clientID: 'baz', id: 'g', str: 'baz-g-str'}, - {clientID: 'foo', id: 'a', str: 'foo-a-str'}, + param: {startAtID: {clientID: 'clientD', id: 'd2'}}, + expectedValues: [ + { + clientID: 'clientD', + id: 'd222', + str: 'd', + }, + { + clientID: 'clientD', + id: 'd333', + str: 'd', + }, + { + clientID: 'clientF', + id: 'f', + str: 'f', + }, ], - expectError: undefined, }, + { - name: 'keystart+limit', - schema: e1, - options: { - startAtID: {clientID: 'bas'}, - limit: 1, + name: 'startAtID and limit', + collectionName: 'entryNoID', + stored: { + '-/p/clientF/entryNoID/': {clientID: 'clientF', str: 'f'}, + '-/p/clientB/entryNoID/': {clientID: 'clientB', str: 'b'}, + '-/p/clientD/entryNoID/': {clientID: 'clientD', str: 'd'}, }, - expected: [{clientID: 'baz', id: 'e', str: 'baz-e-str'}], - expectError: undefined, + param: {startAtID: {clientID: 'clientC'}, limit: 1}, + expectedValues: [{clientID: 'clientD', str: 'd'}], + }, + { + name: 'startAtID and limit', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': {clientID: 'clientB', id: 'b', str: 'b'}, + '-/p/clientD/entryID/id/d': {clientID: 'clientD', id: 'd', str: 'd'}, + }, + param: {startAtID: {clientID: 'clientC'}, limit: 1}, + expectedValues: [{clientID: 'clientD', id: 'd', str: 'd'}], + }, + { + name: 'startAtID with clientID and id and limit', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': { + clientID: 'clientB', + id: 'b', + str: 'b', + }, + '-/p/clientD/entryID/id/d111': { + clientID: 'clientD', + id: 'd111', + str: 'd', + }, + '-/p/clientD/entryID/id/d222': { + clientID: 'clientD', + id: 'd222', + str: 'd', + }, + '-/p/clientD/entryID/id/d333': { + clientID: 'clientD', + id: 'd333', + str: 'd', + }, + }, + param: {startAtID: {clientID: 'clientD', id: 'd2'}, limit: 2}, + expectedValues: [ + { + clientID: 'clientD', + id: 'd222', + str: 'd', + }, + { + clientID: 'clientD', + id: 'd333', + str: 'd', + }, + ], }, ]; for (const f of factories) { for (const c of cases) { - test(c.name, async () => { + test(`${c.name} using ${c.collectionName}`, async () => { const r = f(mutators); - - for (const [clientID, id] of [ - ['foo', 'a'], - ['bar', ''], - ['bar', 'c'], - ['baz', 'e'], - ['baz', 'g'], - ] as const) { + const clientID = await r.clientID; + const { + collectionName, + stored, + param, + expectedValues, + expectedError, + }: Case = replaceClientID(c, '$CLIENT_ID', clientID); + + for (const [key, val] of Object.entries(stored)) { await r.mutate.directWrite({ - key: `-/p/${clientID}/e1/${id}`, - val: {clientID, id, str: `${clientID}-${id}-str`}, + key, + val, }); } - await r.mutate.directWrite({ - key: `-/p/ignore/me`, - val: 'data that should be ignored', - }); - await r.mutate.directWrite({ - key: `-/p/foo`, - val: 'data that should be ignored', - }); - - let error = undefined; - let actual = undefined; + let error; + let actual; try { - actual = await r.query(tx => listE1(tx, c.options)); + if (collectionName === 'entryNoID') { + actual = await r.query(tx => listEntryNoID(tx, param)); + } else { + actual = await r.query(tx => listEntryID(tx, param)); + } } catch (e) { if (e instanceof ZodError) { error = e.format(); @@ -1014,8 +1616,8 @@ suite('list', () => { error = e; } } - expect(error).deep.equal(c.expectError, c.name); - expect(actual).deep.equal(c.expected, c.name); + expect(error).toEqual(expectedError); + expect(actual).toEqual(expectedValues); }); } } @@ -1024,88 +1626,386 @@ suite('list', () => { suite('listIDs', () => { type Case = { name: string; - prefix: string; - options?: ListOptionsForPresence | undefined; - expected?: PresenceEntity[] | undefined; - expectError?: ReadonlyJSONObject | undefined; + collectionName: 'entryNoID' | 'entryID'; + stored: Record; + param: ListOptions | ListOptions | undefined; + expectedValues: ReadonlyJSONValue[] | undefined; + expectedError?: ReadonlyJSONValue; }; const cases: Case[] = [ { name: 'all', - prefix: 'e1', - expected: [ - {clientID: 'bar', id: 'c'}, - {clientID: 'baz', id: 'e'}, - {clientID: 'baz', id: 'g'}, - {clientID: 'foo', id: 'a'}, + collectionName: 'entryNoID', + stored: { + '-/p/clientF/entryNoID/': {clientID: 'clientF', str: 'f'}, + '-/p/clientB/entryNoID/': {clientID: 'clientB', str: 'b'}, + '-/p/clientD/entryNoID/': {clientID: 'clientD', str: 'd'}, + }, + param: undefined, + expectedValues: [ + {clientID: 'clientB'}, + {clientID: 'clientD'}, + {clientID: 'clientF'}, ], - expectError: undefined, }, + { - name: 'keystart', - prefix: 'e1', - options: { - startAtID: {clientID: 'f', id: ''}, + name: 'all', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': {clientID: 'clientB', id: 'b', str: 'b'}, + '-/p/clientD/entryID/id/d': {clientID: 'clientD', id: 'd', str: 'd'}, }, - expected: [{clientID: 'foo', id: 'a'}], - expectError: undefined, + param: undefined, + expectedValues: [ + {clientID: 'clientB', id: 'b'}, + {clientID: 'clientD', id: 'd'}, + {clientID: 'clientF', id: 'f'}, + ], }, + { - name: 'keystart', - prefix: 'e1', - options: { - startAtID: {clientID: 'baz', id: 'e'}, + name: 'startAtID', + collectionName: 'entryNoID', + stored: { + '-/p/clientF/entryNoID/': {clientID: 'clientF', str: 'f'}, + '-/p/clientB/entryNoID/': {clientID: 'clientB', str: 'b'}, + '-/p/clientD/entryNoID/': {clientID: 'clientD', str: 'd'}, + }, + param: {startAtID: {clientID: 'clientC'}}, + expectedValues: [{clientID: 'clientD'}, {clientID: 'clientF'}], + }, + { + name: 'startAtID', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': {clientID: 'clientB', id: 'b', str: 'b'}, + '-/p/clientD/entryID/id/d': {clientID: 'clientD', id: 'd', str: 'd'}, + }, + param: {startAtID: {clientID: 'clientC'}}, + expectedValues: [ + {clientID: 'clientD', id: 'd'}, + {clientID: 'clientF', id: 'f'}, + ], + }, + { + name: 'startAtID with clientID and id', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': { + clientID: 'clientB', + id: 'b', + str: 'b', + }, + '-/p/clientD/entryID/id/d111': { + clientID: 'clientD', + id: 'd111', + str: 'd', + }, + '-/p/clientD/entryID/id/d222': { + clientID: 'clientD', + id: 'd222', + str: 'd', + }, + '-/p/clientD/entryID/id/d333': { + clientID: 'clientD', + id: 'd333', + str: 'd', + }, }, - expected: [ - {clientID: 'baz', id: 'e'}, - {clientID: 'baz', id: 'g'}, - {clientID: 'foo', id: 'a'}, + param: {startAtID: {clientID: 'clientD', id: 'd2'}}, + expectedValues: [ + { + clientID: 'clientD', + id: 'd222', + }, + { + clientID: 'clientD', + id: 'd333', + }, + { + clientID: 'clientF', + id: 'f', + }, ], - expectError: undefined, + }, + + { + name: 'startAtID and limit', + collectionName: 'entryNoID', + stored: { + '-/p/clientF/entryNoID/': {clientID: 'clientF', str: 'f'}, + '-/p/clientB/entryNoID/': {clientID: 'clientB', str: 'b'}, + '-/p/clientD/entryNoID/': {clientID: 'clientD', str: 'd'}, + }, + param: {startAtID: {clientID: 'clientC'}, limit: 1}, + expectedValues: [{clientID: 'clientD'}], }, { - name: 'keystart+limit', - prefix: 'e1', - options: { - startAtID: {clientID: 'bas', id: ''}, - limit: 1, + name: 'startAtID and limit', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': {clientID: 'clientB', id: 'b', str: 'b'}, + '-/p/clientD/entryID/id/d': {clientID: 'clientD', id: 'd', str: 'd'}, }, - expected: [{clientID: 'baz', id: 'e'}], - expectError: undefined, + param: {startAtID: {clientID: 'clientC'}, limit: 1}, + expectedValues: [{clientID: 'clientD', id: 'd'}], + }, + { + name: 'startAtID with clientID and id and limit', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': { + clientID: 'clientB', + id: 'b', + str: 'b', + }, + '-/p/clientD/entryID/id/d111': { + clientID: 'clientD', + id: 'd111', + str: 'd', + }, + '-/p/clientD/entryID/id/d222': { + clientID: 'clientD', + id: 'd222', + str: 'd', + }, + '-/p/clientD/entryID/id/d333': { + clientID: 'clientD', + id: 'd333', + str: 'd', + }, + }, + param: {startAtID: {clientID: 'clientD', id: 'd2'}, limit: 2}, + expectedValues: [ + { + clientID: 'clientD', + id: 'd222', + }, + { + clientID: 'clientD', + id: 'd333', + }, + ], }, ]; for (const f of factories) { for (const c of cases) { - test(c.name, async () => { + test(`${c.name} using ${c.collectionName}`, async () => { const r = f(mutators); - - for (const [clientID, id] of [ - ['foo', 'a'], - ['bar', 'c'], - ['baz', 'e'], - ['baz', 'g'], - ] as const) { + const clientID = await r.clientID; + const { + collectionName, + stored, + param, + expectedValues, + expectedError, + }: Case = replaceClientID(c, '$CLIENT_ID', clientID); + + for (const [key, val] of Object.entries(stored)) { await r.mutate.directWrite({ - key: `-/p/${clientID}/e1/${id}`, - val: {clientID, id, str: `${clientID}-${id}-str`}, + key, + val, }); } - await r.mutate.directWrite({ - key: `-/p/ignore/me`, - val: 'data that should be ignored', - }); - await r.mutate.directWrite({ - key: `-/p/foo`, - val: 'data that should be ignored', - }); + let error; + let actual; + try { + if (collectionName === 'entryNoID') { + actual = await r.query(tx => listIDsEntryNoID(tx, param)); + } else { + actual = await r.query(tx => listIDsEntryID(tx, param)); + } + } catch (e) { + if (e instanceof ZodError) { + error = e.format(); + } else { + error = e; + } + } + expect(error).toEqual(expectedError); + expect(actual).toEqual(expectedValues); + }); + } + } +}); - let error = undefined; - let actual = undefined; +suite('listClientIDs', () => { + type Case = { + name: string; + collectionName: 'entryNoID' | 'entryID'; + stored: Record; + param: ListOptions | ListOptions | undefined; + expectedValues: ReadonlyJSONValue[] | undefined; + expectedError?: ReadonlyJSONValue; + }; + + const cases: Case[] = [ + { + name: 'all', + collectionName: 'entryNoID', + stored: { + '-/p/clientF/entryNoID/': {clientID: 'clientF', str: 'f'}, + '-/p/clientB/entryNoID/': {clientID: 'clientB', str: 'b'}, + '-/p/clientD/entryNoID/': {clientID: 'clientD', str: 'd'}, + }, + param: undefined, + expectedValues: ['clientB', 'clientD', 'clientF'], + }, + + { + name: 'all', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': {clientID: 'clientB', id: 'b', str: 'b'}, + '-/p/clientD/entryID/id/d': {clientID: 'clientD', id: 'd', str: 'd'}, + }, + param: undefined, + expectedValues: ['clientB', 'clientD', 'clientF'], + }, + + { + name: 'startAtID', + collectionName: 'entryNoID', + stored: { + '-/p/clientF/entryNoID/': {clientID: 'clientF', str: 'f'}, + '-/p/clientB/entryNoID/': {clientID: 'clientB', str: 'b'}, + '-/p/clientD/entryNoID/': {clientID: 'clientD', str: 'd'}, + }, + param: {startAtID: {clientID: 'clientC'}}, + expectedValues: ['clientD', 'clientF'], + }, + { + name: 'startAtID', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': {clientID: 'clientB', id: 'b', str: 'b'}, + '-/p/clientD/entryID/id/d': {clientID: 'clientD', id: 'd', str: 'd'}, + }, + param: {startAtID: {clientID: 'clientC'}}, + expectedValues: ['clientD', 'clientF'], + }, + { + name: 'startAtID with clientID and id', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': { + clientID: 'clientB', + id: 'b', + str: 'b', + }, + '-/p/clientD/entryID/id/d111': { + clientID: 'clientD', + id: 'd111', + str: 'd', + }, + '-/p/clientD/entryID/id/d222': { + clientID: 'clientD', + id: 'd222', + str: 'd', + }, + '-/p/clientD/entryID/id/d333': { + clientID: 'clientD', + id: 'd333', + str: 'd', + }, + }, + param: {startAtID: {clientID: 'clientD', id: 'd2'}}, + expectedValues: ['clientD', 'clientF'], + }, + + { + name: 'startAtID and limit', + collectionName: 'entryNoID', + stored: { + '-/p/clientF/entryNoID/': {clientID: 'clientF', str: 'f'}, + '-/p/clientB/entryNoID/': {clientID: 'clientB', str: 'b'}, + '-/p/clientD/entryNoID/': {clientID: 'clientD', str: 'd'}, + }, + param: {startAtID: {clientID: 'clientC'}, limit: 1}, + expectedValues: ['clientD'], + }, + { + name: 'startAtID and limit', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': {clientID: 'clientB', id: 'b', str: 'b'}, + '-/p/clientD/entryID/id/d': {clientID: 'clientD', id: 'd', str: 'd'}, + }, + param: {startAtID: {clientID: 'clientC'}, limit: 1}, + expectedValues: ['clientD'], + }, + { + name: 'startAtID with clientID and id and limit', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': { + clientID: 'clientB', + id: 'b', + str: 'b', + }, + '-/p/clientD/entryID/id/d111': { + clientID: 'clientD', + id: 'd111', + str: 'd', + }, + '-/p/clientD/entryID/id/d222': { + clientID: 'clientD', + id: 'd222', + str: 'd', + }, + '-/p/clientD/entryID/id/d333': { + clientID: 'clientD', + id: 'd333', + str: 'd', + }, + }, + param: {startAtID: {clientID: 'clientD', id: 'd2'}, limit: 2}, + expectedValues: ['clientD', 'clientF'], + }, + ]; + + for (const f of factories) { + for (const c of cases) { + test(`${c.name} using ${c.collectionName}`, async () => { + const r = f(mutators); + const clientID = await r.clientID; + const { + collectionName, + stored, + param, + expectedValues, + expectedError, + }: Case = replaceClientID(c, '$CLIENT_ID', clientID); + + for (const [key, val] of Object.entries(stored)) { + await r.mutate.directWrite({ + key, + val, + }); + } + + let error; + let actual; try { - actual = await r.query(tx => listIDsE1(tx, c.options)); + if (collectionName === 'entryNoID') { + actual = await r.query(tx => listClientIDsEntryNoID(tx, param)); + } else { + actual = await r.query(tx => listClientIDsEntryID(tx, param)); + } } catch (e) { if (e instanceof ZodError) { error = e.format(); @@ -1113,8 +2013,8 @@ suite('listIDs', () => { error = e; } } - expect(error).deep.equal(c.expectError, c.name); - expect(actual).deep.equal(c.expected, c.name); + expect(error).toEqual(expectedError); + expect(actual).toEqual(expectedValues); }); } } @@ -1123,101 +2023,264 @@ suite('listIDs', () => { suite('listEntries', () => { type Case = { name: string; - prefix: string; - schema: ZodTypeAny; - options?: ListOptionsWithLookupID | undefined; - expected?: ReadonlyJSONObject[][] | undefined; - expectError?: ReadonlyJSONObject | undefined; + collectionName: 'entryNoID' | 'entryID'; + stored: Record; + param: ListOptions | ListOptions | undefined; + expectedValues: ReadonlyJSONValue[] | undefined; + expectedError?: ReadonlyJSONValue; }; const cases: Case[] = [ { name: 'all', - prefix: 'e1', - schema: e1, - expected: [ + collectionName: 'entryNoID', + stored: { + '-/p/clientF/entryNoID/': {clientID: 'clientF', str: 'f'}, + '-/p/clientB/entryNoID/': {clientID: 'clientB', str: 'b'}, + '-/p/clientD/entryNoID/': {clientID: 'clientD', str: 'd'}, + }, + param: undefined, + expectedValues: [ + [{clientID: 'clientB'}, {clientID: 'clientB', str: 'b'}], + [{clientID: 'clientD'}, {clientID: 'clientD', str: 'd'}], + [{clientID: 'clientF'}, {clientID: 'clientF', str: 'f'}], + ], + }, + + { + name: 'all', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': {clientID: 'clientB', id: 'b', str: 'b'}, + '-/p/clientD/entryID/id/d': {clientID: 'clientD', id: 'd', str: 'd'}, + }, + param: undefined, + expectedValues: [ [ - {clientID: 'bar', id: 'c'}, - {clientID: 'bar', id: 'c', str: 'bar-c-str'}, + {clientID: 'clientB', id: 'b'}, + {clientID: 'clientB', id: 'b', str: 'b'}, ], [ - {clientID: 'baz', id: 'e'}, - {clientID: 'baz', id: 'e', str: 'baz-e-str'}, + {clientID: 'clientD', id: 'd'}, + {clientID: 'clientD', id: 'd', str: 'd'}, ], [ - {clientID: 'baz', id: 'g'}, - {clientID: 'baz', id: 'g', str: 'baz-g-str'}, + {clientID: 'clientF', id: 'f'}, + {clientID: 'clientF', id: 'f', str: 'f'}, + ], + ], + }, + + { + name: 'startAtID', + collectionName: 'entryNoID', + stored: { + '-/p/clientF/entryNoID/': {clientID: 'clientF', str: 'f'}, + '-/p/clientB/entryNoID/': {clientID: 'clientB', str: 'b'}, + '-/p/clientD/entryNoID/': {clientID: 'clientD', str: 'd'}, + }, + param: {startAtID: {clientID: 'clientC'}}, + expectedValues: [ + [{clientID: 'clientD'}, {clientID: 'clientD', str: 'd'}], + [{clientID: 'clientF'}, {clientID: 'clientF', str: 'f'}], + ], + }, + { + name: 'startAtID', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': {clientID: 'clientB', id: 'b', str: 'b'}, + '-/p/clientD/entryID/id/d': {clientID: 'clientD', id: 'd', str: 'd'}, + }, + param: {startAtID: {clientID: 'clientC'}}, + expectedValues: [ + [ + {clientID: 'clientD', id: 'd'}, + {clientID: 'clientD', id: 'd', str: 'd'}, ], [ - {clientID: 'foo', id: 'a'}, - {clientID: 'foo', id: 'a', str: 'foo-a-str'}, + {clientID: 'clientF', id: 'f'}, + {clientID: 'clientF', id: 'f', str: 'f'}, ], ], - expectError: undefined, }, { - name: 'keystart', - prefix: 'e1', - schema: e1, - options: { - startAtID: {clientID: 'f', id: ''}, + name: 'startAtID with clientID and id', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': { + clientID: 'clientB', + id: 'b', + str: 'b', + }, + '-/p/clientD/entryID/id/d111': { + clientID: 'clientD', + id: 'd111', + str: 'd', + }, + '-/p/clientD/entryID/id/d222': { + clientID: 'clientD', + id: 'd222', + str: 'd', + }, + '-/p/clientD/entryID/id/d333': { + clientID: 'clientD', + id: 'd333', + str: 'd', + }, }, - expected: [ + param: {startAtID: {clientID: 'clientD', id: 'd2'}}, + expectedValues: [ + [ + { + clientID: 'clientD', + id: 'd222', + }, + { + clientID: 'clientD', + id: 'd222', + str: 'd', + }, + ], + [ + { + clientID: 'clientD', + id: 'd333', + }, + { + clientID: 'clientD', + id: 'd333', + str: 'd', + }, + ], [ - {clientID: 'foo', id: 'a'}, - {clientID: 'foo', id: 'a', str: 'foo-a-str'}, + { + clientID: 'clientF', + id: 'f', + }, + { + clientID: 'clientF', + id: 'f', + str: 'f', + }, ], ], - expectError: undefined, }, + { - name: 'keystart+limit', - prefix: 'e1', - schema: e1, - options: { - startAtID: {clientID: 'bas', id: ''}, - limit: 1, + name: 'startAtID and limit', + collectionName: 'entryNoID', + stored: { + '-/p/clientF/entryNoID/': {clientID: 'clientF', str: 'f'}, + '-/p/clientB/entryNoID/': {clientID: 'clientB', str: 'b'}, + '-/p/clientD/entryNoID/': {clientID: 'clientD', str: 'd'}, + }, + param: {startAtID: {clientID: 'clientC'}, limit: 1}, + expectedValues: [ + [{clientID: 'clientD'}, {clientID: 'clientD', str: 'd'}], + ], + }, + { + name: 'startAtID and limit', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': {clientID: 'clientB', id: 'b', str: 'b'}, + '-/p/clientD/entryID/id/d': {clientID: 'clientD', id: 'd', str: 'd'}, + }, + param: {startAtID: {clientID: 'clientC'}, limit: 1}, + expectedValues: [ + [ + {clientID: 'clientD', id: 'd'}, + {clientID: 'clientD', id: 'd', str: 'd'}, + ], + ], + }, + { + name: 'startAtID with clientID and id and limit', + collectionName: 'entryID', + stored: { + '-/p/clientF/entryID/id/f': {clientID: 'clientF', id: 'f', str: 'f'}, + '-/p/clientB/entryID/id/b': { + clientID: 'clientB', + id: 'b', + str: 'b', + }, + '-/p/clientD/entryID/id/d111': { + clientID: 'clientD', + id: 'd111', + str: 'd', + }, + '-/p/clientD/entryID/id/d222': { + clientID: 'clientD', + id: 'd222', + str: 'd', + }, + '-/p/clientD/entryID/id/d333': { + clientID: 'clientD', + id: 'd333', + str: 'd', + }, }, - expected: [ + param: {startAtID: {clientID: 'clientD', id: 'd2'}, limit: 2}, + expectedValues: [ [ - {clientID: 'baz', id: 'e'}, - {clientID: 'baz', id: 'e', str: 'baz-e-str'}, + { + clientID: 'clientD', + id: 'd222', + }, + { + clientID: 'clientD', + id: 'd222', + str: 'd', + }, + ], + [ + { + clientID: 'clientD', + id: 'd333', + }, + { + clientID: 'clientD', + id: 'd333', + str: 'd', + }, ], ], - expectError: undefined, }, ]; for (const f of factories) { for (const c of cases) { - test(c.name, async () => { + test(`${c.name} using ${c.collectionName}`, async () => { const r = f(mutators); - - for (const [clientID, id] of [ - ['foo', 'a'], - ['bar', 'c'], - ['baz', 'e'], - ['baz', 'g'], - ] as const) { + const clientID = await r.clientID; + const { + collectionName, + stored, + param, + expectedValues, + expectedError, + }: Case = replaceClientID(c, '$CLIENT_ID', clientID); + + for (const [key, val] of Object.entries(stored)) { await r.mutate.directWrite({ - key: `-/p/${clientID}/e1/${id}`, - val: {clientID, id, str: `${clientID}-${id}-str`}, + key, + val, }); } - await r.mutate.directWrite({ - key: `-/p/ignore/me`, - val: 'data that should be ignored', - }); - await r.mutate.directWrite({ - key: `-/p/foo`, - val: 'data that should be ignored', - }); - - let error = undefined; - let actual = undefined; + let error; + let actual; try { - actual = await r.query(tx => listEntriesE1(tx, c.options)); + if (collectionName === 'entryNoID') { + actual = await r.query(tx => listEntriesEntryNoID(tx, param)); + } else { + actual = await r.query(tx => listEntriesEntryID(tx, param)); + } } catch (e) { if (e instanceof ZodError) { error = e.format(); @@ -1225,14 +2288,14 @@ suite('listEntries', () => { error = e; } } - expect(error).deep.equal(c.expectError, c.name); - expect(actual).deep.equal(c.expected, c.name); + expect(error).toEqual(expectedError); + expect(actual).toEqual(expectedValues); }); } } }); -test('optionalLogger', async () => { +suite('optionalLogger', () => { type Case = { name: string; logger: OptionalLogger | undefined; @@ -1265,7 +2328,7 @@ test('optionalLogger', async () => { }, }, expected: clientID => [ - `no such entity {"clientID":"${clientID}","id":"a"}, skipping update`, + `no such entity {"clientID":"${clientID}"}, skipping update`, ], }, ]; @@ -1274,14 +2337,20 @@ test('optionalLogger', async () => { for (const f of factories) { for (const c of cases) { - const {update: updateE1} = generatePresence(name, e1.parse, c.logger); - output = undefined; + test(c.name, async () => { + const {update: updateEntryNoID} = generatePresence( + name, + entryNoID.parse, + c.logger, + ); + output = undefined; - const r = f({updateE1}); - const clientID = await r.clientID; + const r = f({updateEntryNoID}); + const clientID = await r.clientID; - await r.mutate.updateE1({clientID, id: 'a', str: 'bar'}); - expect(output, c.name).deep.equal(c.expected?.(clientID)); + await r.mutate.updateEntryNoID({clientID, str: 'bar'}); + expect(output).toEqual(c.expected?.(clientID)); + }); } } }); @@ -1294,7 +2363,7 @@ test('undefined parse', async () => { } as unknown as NodeJS.Process; const name = 'nnn'; - const generated = generatePresence(name); + const generated = generatePresence(name); const {get, list, listIDs} = generated; const r = new Replicache({ @@ -1304,78 +2373,81 @@ test('undefined parse', async () => { }); const clientID = await r.clientID; - let v = await r.query(tx => get(tx, {clientID, id: 'id1'})); - expect(v).eq(undefined); + let v = await r.query(tx => get(tx, {clientID})); + expect(v).toBe(undefined); - await r.mutate.set({clientID, id: 'id1', str: 'bar'}); + await r.mutate.set({clientID, str: 'bar'}); await r.mutate.set({ clientID, id: 'id2', bonk: 'baz', - } as unknown as E1); + } as unknown as EntryNoID); - v = await r.query(tx => get(tx, {clientID, id: 'id1'})); - expect(v).deep.equal({clientID, id: 'id1', str: 'bar'}); - v = await r.query(tx => get(tx, {clientID, id: 'id2'})); - expect(v).deep.equal({clientID, id: 'id2', bonk: 'baz'}); + v = await r.query(tx => get(tx, {clientID})); + expect(v).toEqual({clientID, str: 'bar'}); + v = await r.query(tx => + get(tx, {clientID, id: 'id2'} as unknown as EntryNoID), + ); + expect(v).toEqual({clientID, id: 'id2', bonk: 'baz'}); const l = await r.query(tx => list(tx)); - expect(l).deep.equal([ - {clientID, id: 'id1', str: 'bar'}, + expect(l).toEqual([ + {clientID, str: 'bar'}, {clientID, id: 'id2', bonk: 'baz'}, ]); const l2 = await r.query(tx => listIDs(tx)); - expect(l2).deep.equal([ - {clientID, id: 'id1'}, - {clientID, id: 'id2'}, - ]); + expect(l2).toEqual([{clientID}, {clientID, id: 'id2'}]); }); test('parse key', () => { const name = 'foo'; - expect(parseKeyToID(name, '-/p/clientID1/foo/id1')).deep.equals({ + expect(parseKeyToID(name, '-/p/clientID1/foo/id/id1')).toEqual({ clientID: 'clientID1', id: 'id1', }); - expect(parseKeyToID(name, '-/p/clientID1/bar/id1')).equals(undefined); - expect(parseKeyToID(name, '-/p/clientID1/foo/id1/')).equals(undefined); - expect(parseKeyToID(name, '-/p/clientID1/foo/id1/more')).equals(undefined); - expect(parseKeyToID(name, '-/p/clientID1/foo/')).deep.equals({ + expect(parseKeyToID(name, '-/p/clientID1/foo/id/')).toEqual({ clientID: 'clientID1', id: '', }); - expect(parseKeyToID(name, '-/p/clientID1/foo')).equals(undefined); - expect(parseKeyToID(name, '-/p/clientID1/')).equals(undefined); - expect(parseKeyToID(name, '-/p/clientID1')).equals(undefined); - expect(parseKeyToID(name, '-/p/')).equals(undefined); - expect(parseKeyToID(name, '-/p')).equals(undefined); - expect(parseKeyToID(name, '-/')).equals(undefined); - expect(parseKeyToID(name, '-')).equals(undefined); - expect(parseKeyToID(name, '')).equals(undefined); - expect(parseKeyToID(name, 'baz')).equals(undefined); + expect(parseKeyToID(name, '-/p/clientID1/foo/')).toEqual({ + clientID: 'clientID1', + }); + + expect(parseKeyToID(name, '-/p/clientID1')).toBe(undefined); + expect(parseKeyToID(name, '-/p/clientID1/')).toBe(undefined); + expect(parseKeyToID(name, '-/p/clientID1/xxx')).toBe(undefined); + expect(parseKeyToID(name, '-/p/clientID1/foo')).toBe(undefined); + expect(parseKeyToID(name, '-/p/clientID1/foo/id')).toBe(undefined); + expect(parseKeyToID(name, '-/p//')).toBe(undefined); + expect(parseKeyToID(name, '-/p/clientID1/foo/id/id1/')).toBe(undefined); + expect(parseKeyToID(name, '-/p/clientID1/foo/id//')).toBe(undefined); + expect(parseKeyToID(name, '-/p/')).toBe(undefined); + expect(parseKeyToID(name, '-/p')).toBe(undefined); + expect(parseKeyToID(name, '-/')).toBe(undefined); + expect(parseKeyToID(name, '-')).toBe(undefined); + expect(parseKeyToID(name, '')).toBe(undefined); + expect(parseKeyToID(name, 'mango')).toBe(undefined); }); test('normalizeScanOptions', () => { expect(normalizeScanOptions(undefined)).undefined; - expect(normalizeScanOptions({})).deep.equal({ + expect(normalizeScanOptions({})).toEqual({ startAtID: undefined, limit: undefined, }); - expect(normalizeScanOptions({limit: 123})).deep.equal({ + expect(normalizeScanOptions({limit: 123})).toEqual({ startAtID: undefined, limit: 123, }); - expect(normalizeScanOptions({startAtID: {}})).deep.equal({ - startAtID: {clientID: '', id: ''}, - limit: undefined, - }); - expect(normalizeScanOptions({startAtID: {id: 'a'}})).deep.equal({ - startAtID: {clientID: '', id: 'a'}, + expect( + normalizeScanOptions({startAtID: {clientID: 'cid', id: 'a'}}), + ).toEqual({ + startAtID: {clientID: 'cid', id: 'a'}, limit: undefined, }); - expect(normalizeScanOptions({startAtID: {clientID: 'b'}})).deep.equal({ - startAtID: {clientID: 'b', id: ''}, + expect(normalizeScanOptions({startAtID: {clientID: 'b'}})).toEqual({ + startAtID: {clientID: 'b'}, limit: undefined, }); }); diff --git a/src/generate-presence.ts b/src/generate-presence.ts index f286afd..eea77df 100644 --- a/src/generate-presence.ts +++ b/src/generate-presence.ts @@ -1,14 +1,14 @@ import {OptionalLogger} from '@rocicorp/logger'; import { + FirstKeyFunc, IDFromEntityFunc, KeyFromEntityFunc, KeyFromLookupIDFunc, KeyToIDFunc, - ListOptionsWithLookupID, + ListOptionsWith, Parse, ParseInternal, ReadTransaction, - Update, WriteTransaction, deleteImpl, getImpl, @@ -19,123 +19,193 @@ import { listImpl, maybeParse, mustGetImpl, + scan, setImpl, updateImpl, } from './generate.js'; +/** + * For presence entities there are two common cases: + * 1. The entity does not have an `id` field. Then there can only be one entity + * per client. This case is useful for keeping track of things like the + * cursor position. + * 2. The entity has an `id` field. Then there can be multiple entities per + * client. This case is useful for keeping track of things like multiple + * selection or multiple cursors (aka multi touch). + */ export type PresenceEntity = { clientID: string; - id: string; + id?: string; }; +type IsIDMissing = 'id' extends keyof T ? false : true; + /** - * Like {@link PresenceEntity}, but with the fields optional. This is used when - * doing get, has and delete operations where the clientID and id fields are - * optional. + * Like {@link PresenceEntity}, but with the clientID optional. This is used + * when doing get, has and delete operations where the clientID field defaults + * to the current client. */ -export type PresenceID = Partial; +export type PresenceID = + IsIDMissing extends false + ? { + clientID?: string; + id: string; + } + : { + clientID?: string; + }; + +export type StartAtID = + IsIDMissing extends true ? {clientID: string} : PresenceEntity; + +type ListID = + IsIDMissing extends true + ? {clientID: string} + : undefined extends T['id'] + ? PresenceEntity + : {clientID: string; id: string}; /** - * When mutating an entity, you can omit the `clientID` and `id` fields. This - * type marks those fields as optional. + * When mutating an entity, you can omit the `clientID`. This type marks that + * field as optional. */ -export type OptionalIDs = PresenceID & - Omit; +export type OptionalClientID = { + clientID?: string | undefined; +} & Omit; + +export type ListOptions = { + startAtID?: StartAtID; + limit?: number; +}; -export type ListOptionsForPresence = ListOptionsWithLookupID; +export type Update = + IsIDMissing extends false ? Pick & Partial : Partial; export type GeneratePresenceResult = { /** Write `value`, overwriting any previous version of same value. */ - set: (tx: WriteTransaction, value: OptionalIDs) => Promise; + set: (tx: WriteTransaction, value: OptionalClientID) => Promise; /** * Write `value`, overwriting any previous version of same value. * @deprecated Use `set` instead. */ - put: (tx: WriteTransaction, value: OptionalIDs) => Promise; + put: (tx: WriteTransaction, value: OptionalClientID) => Promise; /** Write `value` only if no previous version of this value exists. */ - init: (tx: WriteTransaction, value: OptionalIDs) => Promise; + init: (tx: WriteTransaction, value: OptionalClientID) => Promise; /** Update existing value with new fields. */ - update: (tx: WriteTransaction, value: Update) => Promise; + update: (tx: WriteTransaction, value: Update) => Promise; /** Delete any existing value or do nothing if none exist. */ - delete: (tx: WriteTransaction, id?: PresenceID) => Promise; + delete: (tx: WriteTransaction, id?: PresenceID) => Promise; /** Return true if specified value exists, false otherwise. */ - has: (tx: ReadTransaction, id?: PresenceID) => Promise; + has: (tx: ReadTransaction, id?: PresenceID) => Promise; /** Get value by ID, or return undefined if none exists. */ - get: (tx: ReadTransaction, id?: PresenceID) => Promise; + get: (tx: ReadTransaction, id?: PresenceID) => Promise; /** Get value by ID, or throw if none exists. */ - mustGet: (tx: ReadTransaction, id?: PresenceID) => Promise; + mustGet: (tx: ReadTransaction, id?: PresenceID) => Promise; /** List values matching criteria. */ - list: (tx: ReadTransaction, options?: ListOptionsForPresence) => Promise; - /** List ids matching criteria. */ + list: (tx: ReadTransaction, options?: ListOptions) => Promise; + + /** + * List ids matching criteria. Here the id is `{clientID: string}` if the + * entry has no `id` field, otherwise it is `{clientID: string, id: string}`. + */ listIDs: ( tx: ReadTransaction, - options?: ListOptionsForPresence, - ) => Promise; + options?: ListOptions, + ) => Promise[]>; + + /** + * List clientIDs matching criteria. Unlike listIDs this returns an array of strings + * consisting of the clientIDs + */ + listClientIDs: ( + tx: ReadTransaction, + options?: ListOptions, + ) => Promise; + /** List [id, value] entries matching criteria. */ listEntries: ( tx: ReadTransaction, - options?: ListOptionsForPresence, - ) => Promise<[PresenceEntity, T][]>; + options?: ListOptions, + ) => Promise<[ListID, T][]>; }; const presencePrefix = '-/p/'; -function keyFromID(name: string, entity: PresenceEntity) { +export function keyFromID(name: string, entity: PresenceEntity) { const {clientID, id} = entity; - return `${presencePrefix}${clientID}/${name}/${id}`; + if (id !== undefined) { + return `${presencePrefix}${clientID}/${name}/id/${id}`; + } + return `${presencePrefix}${clientID}/${name}/`; } export function parseKeyToID( name: string, key: string, -): PresenceEntity | undefined { +): {clientID: string} | {clientID: string; id: string} | undefined { const parts = key.split('/'); if ( - parts.length !== 5 || + parts.length < 5 || parts[0] !== '-' || parts[1] !== 'p' || parts[2] === '' || // clientID - parts[3] !== name + parts[3] !== name || + (parts[4] !== 'id' && parts[4] !== '') ) { return undefined; } - return {clientID: parts[2], id: parts[4]}; + + // Now we know the key starts with '-/p/{clientID}/name/' or '-/p/{clientID}/name/id/{id}' + if (parts.length === 5 && parts[4] === '') { + return {clientID: parts[2]}; + } + if (parts.length === 6 && parts[4] === 'id') { + return {clientID: parts[2], id: parts[5]}; + } + + return undefined; } const idFromEntity: IDFromEntityFunc = ( _tx: ReadTransaction, entity: PresenceEntity, -) => ({clientID: entity.clientID, id: entity.id}); +) => + entity.id === undefined + ? {clientID: entity.clientID} + : {clientID: entity.clientID, id: entity.id}; -function normalizePresenceID( +function normalizePresenceID( tx: {clientID: string}, base: Partial | undefined, -) { - if (base === undefined) { - return {clientID: tx.clientID, id: ''}; +): ListID { + // When we replay mutations (delete in this case) undefined arguments gets converted to null. + // + // deleteEntity() + // + // becomes: + // + // deleteEntity(null) + // + // when rebasing. + // eslint-disable-next-line eqeqeq + if (base == null) { + return {clientID: tx.clientID} as ListID; } - return { - clientID: base.clientID ?? tx.clientID, - id: base.id ?? '', - }; + const {clientID = tx.clientID, id} = base; + return (id === undefined ? {clientID} : {clientID, id}) as ListID; } -function normalizeUpdate( +function normalizeForUpdate( tx: {clientID: string}, - update: T, -): T & PresenceEntity { - validateMutate(tx, update); - return { - ...update, - clientID: tx.clientID, - id: update.id ?? '', - }; + v: Update, +): Update & {clientID: string} { + return normalizeForSet(tx, v); } -function normalizeForSet( - tx: {clientID: string}, - v: V, -): V & PresenceEntity { +function normalizeForSet< + T extends PresenceEntity, + V extends OptionalClientID, +>(tx: {clientID: string}, v: V): V & {clientID: string} { if (v === null) { throw new TypeError('Expected object, received null'); } @@ -145,33 +215,31 @@ function normalizeForSet( validateMutate(tx, v); - type R = V & PresenceEntity; - - if (v.clientID === undefined && v.id === undefined) { - return {...v, clientID: tx.clientID, id: ''}; + if ('clientID' in v) { + return v as V & {clientID: string}; } - if (v.id === undefined) { - return {...v, id: ''} as R; - } - if (v.clientID === undefined) { - return {...v, clientID: tx.clientID} as R; - } - - return v as R; + return {...v, clientID: tx.clientID}; } -export function normalizeScanOptions(options?: ListOptionsForPresence) { +export function normalizeScanOptions( + options?: ListOptions, +): ListOptionsWith> | undefined { if (!options) { return options; } const {startAtID, limit} = options; return { - startAtID: startAtID && normalizePresenceID({clientID: ''}, startAtID), + startAtID: + startAtID && + (normalizePresenceID({clientID: ''}, startAtID) as ListID), limit, }; } -function validateMutate(tx: {clientID: string}, id: PresenceID): void { +function validateMutate( + tx: {clientID: string}, + id: {clientID?: string | undefined}, +): void { if (id.clientID && id.clientID !== tx.clientID) { throw new Error( `Can only mutate own entities. Expected clientID "${tx.clientID}" but received "${id.clientID}"`, @@ -179,6 +247,16 @@ function validateMutate(tx: {clientID: string}, id: PresenceID): void { } } +export function generatePresence>( + name: string, + parse?: Parse | undefined, + logger?: OptionalLogger, +): GeneratePresenceResult; +export function generatePresence( + name: string, + parse?: Parse | undefined, + logger?: OptionalLogger, +): GeneratePresenceResult; export function generatePresence( name: string, parse: Parse | undefined = undefined, @@ -192,12 +270,12 @@ export function generatePresence( keyFromID(name, entity); const keyFromIDLocal: KeyFromLookupIDFunc = id => keyFromID(name, id); - const parseKeyToIDLocal: KeyToIDFunc = (key: string) => - parseKeyToID(name, key); + const parseKeyToIDLocal: KeyToIDFunc> = (key: string) => + parseKeyToID(name, key) as ListID; const firstKey = () => presencePrefix; const parseInternal: ParseInternal = (_, v) => maybeParse(parse, v); const parseAndValidateClientIDForMutate: ParseInternal = (tx, v) => - parseInternal(tx, normalizeForSet(tx, v as OptionalIDs)); + parseInternal(tx, normalizeForSet(tx, v as unknown as OptionalClientID)); const set: GeneratePresenceResult['set'] = (tx, value) => setImpl(keyFromEntityLocal, parseAndValidateClientIDForMutate, tx, value); @@ -215,22 +293,23 @@ export function generatePresence( updateImpl( keyFromEntityLocal, idFromEntity, + parseInternal, parseAndValidateClientIDForMutate, tx, - normalizeUpdate(tx, update), + normalizeForUpdate(tx, update), logger, ), - delete: (tx, id) => + delete: (tx, id?) => deleteImpl( keyFromIDLocal, validateMutate, tx, normalizePresenceID(tx, id), ), - has: (tx, id) => hasImpl(keyFromIDLocal, tx, normalizePresenceID(tx, id)), - get: (tx, id) => + has: (tx, id?) => hasImpl(keyFromIDLocal, tx, normalizePresenceID(tx, id)), + get: (tx, id?) => getImpl(keyFromIDLocal, parseInternal, tx, normalizePresenceID(tx, id)), - mustGet: (tx, id) => + mustGet: (tx, id?) => mustGetImpl( keyFromIDLocal, parseInternal, @@ -247,7 +326,15 @@ export function generatePresence( normalizeScanOptions(options), ), listIDs: (tx, options?) => - listIDsImpl( + listIDsImpl>( + keyFromIDLocal, + parseKeyToIDLocal, + firstKey, + tx, + normalizeScanOptions(options), + ), + listClientIDs: (tx, options?) => + listClientIDsImpl( keyFromIDLocal, parseKeyToIDLocal, firstKey, @@ -265,3 +352,48 @@ export function generatePresence( ), }; } + +async function listClientIDsImpl( + keyFromID: KeyFromLookupIDFunc, + keyToID: KeyToIDFunc, + firstKey: FirstKeyFunc, + tx: ReadTransaction, + options?: ListOptionsWith, +): Promise { + // For this function we might get more than one entry per clientID in case there are entries like: + // + // -/p/clientID1/name/id/id1 + // -/p/clientID1/name/id/id2 + // -/p/clientID1/name/id/id3 + // + // We therefore remove the limit passed into scan and manage the limit ourselves. + // We also need to make sure we don't return the same clientID twice. + + const result: string[] = []; + const keyToID2 = (key: string): string | undefined => { + const id = keyToID(key); + return id?.clientID; + }; + let last = undefined; + const fixedOptions = { + ...options, + limit: undefined, + }; + let {limit: i = Infinity} = options ?? {}; + for await (const [k] of scan( + keyFromID, + keyToID2, + firstKey, + tx, + fixedOptions, + )) { + if (k !== last) { + if (--i < 0) { + break; + } + last = k; + result.push(k); + } + } + return result; +} diff --git a/src/generate.test.ts b/src/generate.test.ts index ecd07b5..f95684a 100644 --- a/src/generate.test.ts +++ b/src/generate.test.ts @@ -445,6 +445,13 @@ suite('update', () => { expected: {id, str: 'baz', optStr: 'bar'}, expectError: undefined, }, + { + name: 'valid-update, no str', + prev: {id, str: 'foo', optStr: 'bar'}, + update: {id}, + expected: {id, str: 'foo', optStr: 'bar'}, + expectError: undefined, + }, ]; for (const f of factories) { @@ -525,6 +532,7 @@ suite('list', () => { name: string; prefix: string; schema: ZodTypeAny; + stored: Record; options?: ListOptions | undefined; expected?: ReadonlyJSONValue[] | undefined; expectError?: ReadonlyJSONValue | undefined; @@ -535,6 +543,12 @@ suite('list', () => { name: 'all', prefix: 'e1', schema: e1, + stored: { + 'e1-not-an-entity': 'not an entity', + 'e1/foo': {id: 'foo', str: 'foostr'}, + 'e1/bar': {id: 'bar', str: 'barstr'}, + 'e1/baz': {id: 'baz', str: 'bazstr'}, + }, expected: [ {id: 'bar', str: 'barstr'}, {id: 'baz', str: 'bazstr'}, @@ -546,6 +560,12 @@ suite('list', () => { name: 'keystart', prefix: 'e1', schema: e1, + stored: { + 'a': 'not an entity', + 'e1/foo': {id: 'foo', str: 'foostr'}, + 'e1/bar': {id: 'bar', str: 'barstr'}, + 'e1/baz': {id: 'baz', str: 'bazstr'}, + }, options: { startAtID: 'f', }, @@ -556,6 +576,12 @@ suite('list', () => { name: 'keystart+limit', prefix: 'e1', schema: e1, + stored: { + 'e1/foo': {id: 'foo', str: 'foostr'}, + 'e1/bar': {id: 'bar', str: 'barstr'}, + 'e1/baz': {id: 'baz', str: 'bazstr'}, + 'e11': 'not an entity', + }, options: { startAtID: 'bas', limit: 1, @@ -570,18 +596,9 @@ suite('list', () => { test(c.name, async () => { const r = f(mutators); - await r.mutate.directWrite({ - key: `e1/foo`, - val: {id: 'foo', str: 'foostr'}, - }); - await r.mutate.directWrite({ - key: `e1/bar`, - val: {id: 'bar', str: 'barstr'}, - }); - await r.mutate.directWrite({ - key: `e1/baz`, - val: {id: 'baz', str: 'bazstr'}, - }); + for (const [key, val] of Object.entries(c.stored)) { + await r.mutate.directWrite({key, val}); + } let error = undefined; let actual = undefined; diff --git a/src/generate.ts b/src/generate.ts index 0a77bf4..0c6d5b4 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -8,7 +8,9 @@ export type Entity = { id: string; }; -export type Update = Entity & Partial; +export type Update = Entity & Partial; + +type UpdateWith = Entity & Partial; /** * A function that can parse a JSON value into a specific type. @@ -83,7 +85,7 @@ export type GenerateResult = { /** Write `value` only if no previous version of this value exists. */ init: (tx: WriteTransaction, value: T) => Promise; /** Update existing value with new fields. */ - update: (tx: WriteTransaction, value: Update) => Promise; + update: (tx: WriteTransaction, value: Update) => Promise; /** Delete any existing value or do nothing if none exist. */ delete: (tx: WriteTransaction, id: string) => Promise; /** Return true if specified value exists, false otherwise. */ @@ -118,7 +120,7 @@ export function generate( const keyToID = (key: string) => id(prefix, key); const idFromEntity: IDFromEntityFunc = (_tx, entity) => entity.id; - const firstKey = () => prefix; + const firstKey = () => key(prefix, ''); const parseInternal: ParseInternal = (_, val) => maybeParse(parse, val); const set: GenerateResult['set'] = (tx, value) => setImpl(keyFromEntity, parseInternal, tx, value); @@ -132,6 +134,7 @@ export function generate( keyFromEntity, idFromEntity, parseInternal, + parseInternal, tx, update, logger, @@ -246,20 +249,21 @@ export async function updateImpl< >( keyFromEntity: KeyFromEntityFunc, idFromEntity: IDFromEntityFunc, - parse: ParseInternal, + parseExisting: ParseInternal, + parseNew: ParseInternal, tx: WriteTransaction, - update: Update, + update: UpdateWith, logger: OptionalLogger, ) { const k = keyFromEntity(tx, update); - const prev = await getInternal(parse, tx, k); + const prev = await getInternal(parseExisting, tx, k); if (prev === undefined) { const id = idFromEntity(tx, update); logger.debug?.(`no such entity ${JSON.stringify(id)}, skipping update`); return; } const next = {...prev, ...update}; - const parsed = parse(tx, next); + const parsed = parseNew(tx, next); await tx.set(k, parsed); } @@ -278,12 +282,12 @@ export type ListOptions = { limit?: number; }; -async function* scan( +export async function* scan( keyFromLookupID: KeyFromLookupIDFunc, keyToID: KeyToIDFunc, firstKey: FirstKeyFunc, tx: ReadTransaction, - options?: ListOptionsWithLookupID, + options?: ListOptionsWith, ): AsyncIterable> { const {startAtID, limit} = options ?? {}; const fk = firstKey(); @@ -303,7 +307,7 @@ async function* scan( } } -export type ListOptionsWithLookupID = { +export type ListOptionsWith = { startAtID?: ID; limit?: number; }; @@ -314,7 +318,7 @@ export async function listImpl( firstKey: FirstKeyFunc, parse: Parse | undefined, tx: ReadTransaction, - options?: ListOptionsWithLookupID, + options?: ListOptionsWith, ) { const result = []; for await (const [, v] of scan(keyFromID, keyToID, firstKey, tx, options)) { @@ -328,7 +332,7 @@ export async function listIDsImpl( keyToID: KeyToIDFunc, firstKey: FirstKeyFunc, tx: ReadTransaction, - options?: ListOptionsWithLookupID, + options?: ListOptionsWith, ): Promise { const result: ID[] = []; for await (const [k] of scan(keyFromID, keyToID, firstKey, tx, options)) { @@ -347,7 +351,7 @@ export async function listEntriesImpl< firstKey: FirstKeyFunc, parse: Parse | undefined, tx: ReadTransaction, - options?: ListOptionsWithLookupID, + options?: ListOptionsWith, ): Promise<[ID, T][]> { const result: [ID, T][] = []; for await (const [k, v] of scan(keyFromID, keyToID, firstKey, tx, options)) { diff --git a/src/index.ts b/src/index.ts index 673f8ab..93c7ecc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,11 @@ export { generatePresence, type GeneratePresenceResult, - type ListOptionsForPresence, - type OptionalIDs, + type OptionalClientID, type PresenceEntity, type PresenceID, + type ListOptions as PresenceListOptions, + type Update as PresenceUpdate, } from './generate-presence.js'; export { generate, @@ -12,7 +13,6 @@ export { type Entity, type GenerateResult, type ListOptions, - type ListOptionsWithLookupID, type Parse, type ReadTransaction, type ScanOptions, diff --git a/vitest.config.ts b/vitest.config.ts index 31db752..6b3d321 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,8 +2,23 @@ import {defineConfig} from 'vitest/config'; export default defineConfig({ test: { - onConsoleLog() { - return false; + onConsoleLog(log) { + if ( + log.includes('Skipping license check for TEST_LICENSE_KEY.') || + log.includes('REPLICACHE LICENSE NOT VALID') || + log.includes('enableAnalytics false') + ) { + return false; + } + }, + browser: { + enabled: true, + provider: 'playwright', + headless: true, + name: 'chromium', + }, + typecheck: { + enabled: false, }, }, });