From 36209f7172d23e669d5775f4666c7cc7365580bd Mon Sep 17 00:00:00 2001 From: msfstef Date: Tue, 29 Oct 2024 11:55:33 +0200 Subject: [PATCH 1/6] Add rows API for shape --- packages/typescript-client/src/shape.ts | 8 ++++++++ packages/typescript-client/test/client.test.ts | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/packages/typescript-client/src/shape.ts b/packages/typescript-client/src/shape.ts index 74bdd5b783..4bd0e663fe 100644 --- a/packages/typescript-client/src/shape.ts +++ b/packages/typescript-client/src/shape.ts @@ -69,6 +69,14 @@ export class Shape = Row> { return this.#stream.isUpToDate } + get rows(): Promise { + return this.value.then((v) => Array.from(v.values())) + } + + get rowsSync(): T[] { + return Array.from(this.valueSync.values()) + } + get value(): Promise> { return new Promise((resolve, reject) => { if (this.#stream.isUpToDate) { diff --git a/packages/typescript-client/test/client.test.ts b/packages/typescript-client/test/client.test.ts index 0170033de9..3443308c7a 100644 --- a/packages/typescript-client/test/client.test.ts +++ b/packages/typescript-client/test/client.test.ts @@ -14,8 +14,10 @@ describe(`Shape`, () => { }) const shape = new Shape(shapeStream) const map = await shape.value + const rows = await shape.rows expect(map).toEqual(new Map()) + expect(rows).toEqual([]) expect(shape.lastSyncedAt()).toBeGreaterThanOrEqual(start) expect(shape.lastSyncedAt()).toBeLessThanOrEqual(Date.now()) expect(shape.lastSynced()).toBeLessThanOrEqual(Date.now() - start) @@ -48,6 +50,9 @@ describe(`Shape`, () => { }) expect(map).toEqual(expectedValue) + expect(shape.rowsSync).toEqual([ + { id: id, title: `test title`, priority: 10 }, + ]) expect(shape.lastSyncedAt()).toBeGreaterThanOrEqual(start) expect(shape.lastSyncedAt()).toBeLessThanOrEqual(Date.now()) expect(shape.lastSynced()).toBeLessThanOrEqual(Date.now() - start) From 03f71460ad02623a28a96e1afdc6a4bcab79a82e Mon Sep 17 00:00:00 2001 From: msfstef Date: Tue, 29 Oct 2024 11:58:19 +0200 Subject: [PATCH 2/6] Use `rowsSync` API on react hooks --- packages/react-hooks/src/react-hooks.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-hooks/src/react-hooks.tsx b/packages/react-hooks/src/react-hooks.tsx index adcdb77fb6..3e2014b900 100644 --- a/packages/react-hooks/src/react-hooks.tsx +++ b/packages/react-hooks/src/react-hooks.tsx @@ -96,7 +96,7 @@ function parseShapeData>( shape: Shape ): UseShapeResult { return { - data: [...shape.valueSync.values()], + data: shape.rowsSync, isLoading: shape.isLoading(), lastSyncedAt: shape.lastSyncedAt(), isError: shape.error !== false, From 3f4720d275860055a747013b59fd5ad8f45cb52a Mon Sep 17 00:00:00 2001 From: msfstef Date: Tue, 29 Oct 2024 11:59:18 +0200 Subject: [PATCH 3/6] Add changeset --- .changeset/friendly-toes-check.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/friendly-toes-check.md diff --git a/.changeset/friendly-toes-check.md b/.changeset/friendly-toes-check.md new file mode 100644 index 0000000000..035dc75f73 --- /dev/null +++ b/.changeset/friendly-toes-check.md @@ -0,0 +1,6 @@ +--- +"@electric-sql/client": patch +"@electric-sql/react": patch +--- + +Implement `rows` and `rowsSync` getters on `Shape` interface for easier data access. From d3f959c5017335ac7d315e4e9e65157ff7b29eb6 Mon Sep 17 00:00:00 2001 From: msfstef Date: Tue, 29 Oct 2024 15:56:46 +0200 Subject: [PATCH 4/6] Change shape to use `rows` everywhere --- packages/react-hooks/src/react-hooks.tsx | 2 +- packages/typescript-client/README.md | 6 +- packages/typescript-client/src/shape.ts | 33 ++--- .../typescript-client/test/client.test-d.ts | 10 +- .../typescript-client/test/client.test.ts | 119 +++++++++--------- .../test/integration.test.ts | 8 +- website/docs/api/clients/typescript.md | 6 +- website/docs/guides/shapes.md | 6 +- 8 files changed, 93 insertions(+), 97 deletions(-) diff --git a/packages/react-hooks/src/react-hooks.tsx b/packages/react-hooks/src/react-hooks.tsx index 3e2014b900..5928616b72 100644 --- a/packages/react-hooks/src/react-hooks.tsx +++ b/packages/react-hooks/src/react-hooks.tsx @@ -19,7 +19,7 @@ export async function preloadShape = Row>( ): Promise> { const shapeStream = getShapeStream(options) const shape = getShape(shapeStream) - await shape.value + await shape.rows return shape } diff --git a/packages/typescript-client/README.md b/packages/typescript-client/README.md index 3cc59033e7..8b3e452edf 100644 --- a/packages/typescript-client/README.md +++ b/packages/typescript-client/README.md @@ -71,11 +71,11 @@ const stream = new ShapeStream({ const shape = new Shape(stream) // Returns promise that resolves with the latest shape data once it's fully loaded -await shape.value +await shape.rows // passes subscribers shape data when the shape updates -shape.subscribe(shapeData => { - // shapeData is a Map of the latest value of each row in a shape. +shape.subscribe(({ rows }) => { + // rows is an array of the latest value of each row in a shape. } ``` diff --git a/packages/typescript-client/src/shape.ts b/packages/typescript-client/src/shape.ts index 4bd0e663fe..0965aa4811 100644 --- a/packages/typescript-client/src/shape.ts +++ b/packages/typescript-client/src/shape.ts @@ -4,13 +4,14 @@ import { FetchError } from './error' import { ShapeStreamInterface } from './client' export type ShapeData = Row> = Map -export type ShapeChangedCallback = Row> = ( +export type ShapeChangedCallback = Row> = (data: { value: ShapeData -) => void + rows: T[] +}) => void /** * A Shape is an object that subscribes to a shape log, - * keeps a materialised shape `.value` in memory and + * keeps a materialised shape `.rows` in memory and * notifies subscribers when the value has changed. * * It can be used without a framework and as a primitive @@ -24,19 +25,19 @@ export type ShapeChangedCallback = Row> = ( * const shape = new Shape(shapeStream) * ``` * - * `value` returns a promise that resolves the Shape data once the Shape has been + * `rows` returns a promise that resolves the Shape data once the Shape has been * fully loaded (and when resuming from being offline): * - * const value = await shape.value + * const rows = await shape.rows * - * `valueSync` returns the current data synchronously: + * `currentRows` returns the current data synchronously: * - * const value = shape.valueSync + * const rows = shape.currentRows * * Subscribe to updates. Called whenever the shape updates in Postgres. * - * shape.subscribe(shapeData => { - * console.log(shapeData) + * shape.subscribe(({ rows }) => { + * console.log(rows) * }) */ export class Shape = Row> { @@ -73,25 +74,25 @@ export class Shape = Row> { return this.value.then((v) => Array.from(v.values())) } - get rowsSync(): T[] { - return Array.from(this.valueSync.values()) + get currentRows(): T[] { + return Array.from(this.currentValue.values()) } get value(): Promise> { return new Promise((resolve, reject) => { if (this.#stream.isUpToDate) { - resolve(this.valueSync) + resolve(this.currentValue) } else { - const unsubscribe = this.subscribe((shapeData) => { + const unsubscribe = this.subscribe(({ value }) => { unsubscribe() if (this.#error) reject(this.#error) - resolve(shapeData) + resolve(value) }) } }) } - get valueSync() { + get currentValue() { return this.#data } @@ -200,7 +201,7 @@ export class Shape = Row> { #notify(): void { this.#subscribers.forEach((callback) => { - callback(this.valueSync) + callback({ value: this.currentValue, rows: this.currentRows }) }) } } diff --git a/packages/typescript-client/test/client.test-d.ts b/packages/typescript-client/test/client.test-d.ts index 1a81cc69dd..61318ec552 100644 --- a/packages/typescript-client/test/client.test-d.ts +++ b/packages/typescript-client/test/client.test-d.ts @@ -56,8 +56,9 @@ describe(`client`, () => { expectTypeOf(shape).toEqualTypeOf>() - shape.subscribe((data) => { - expectTypeOf(data).toEqualTypeOf>() + shape.subscribe(({ value, rows }) => { + expectTypeOf(value).toEqualTypeOf>() + expectTypeOf(rows).toEqualTypeOf() }) const data = await shape.value @@ -76,8 +77,9 @@ describe(`client`, () => { const shape = new Shape(shapeStream) expectTypeOf(shape).toEqualTypeOf>() - shape.subscribe((data) => { - expectTypeOf(data).toEqualTypeOf>() + shape.subscribe(({ value, rows }) => { + expectTypeOf(value).toEqualTypeOf>() + expectTypeOf(rows).toEqualTypeOf() }) const data = await shape.value diff --git a/packages/typescript-client/test/client.test.ts b/packages/typescript-client/test/client.test.ts index 3443308c7a..e67a8505b9 100644 --- a/packages/typescript-client/test/client.test.ts +++ b/packages/typescript-client/test/client.test.ts @@ -13,11 +13,9 @@ describe(`Shape`, () => { url: `${BASE_URL}/v1/shape/${issuesTableUrl}`, }) const shape = new Shape(shapeStream) - const map = await shape.value - const rows = await shape.rows - expect(map).toEqual(new Map()) - expect(rows).toEqual([]) + expect(await shape.value).toEqual(new Map()) + expect(await shape.rows).toEqual([]) expect(shape.lastSyncedAt()).toBeGreaterThanOrEqual(start) expect(shape.lastSyncedAt()).toBeLessThanOrEqual(Date.now()) expect(shape.lastSynced()).toBeLessThanOrEqual(Date.now() - start) @@ -25,7 +23,6 @@ describe(`Shape`, () => { it(`should notify with the initial value`, async ({ issuesTableUrl, - issuesTableKey, insertIssues, aborter, }) => { @@ -38,21 +35,11 @@ describe(`Shape`, () => { }) const shape = new Shape(shapeStream) - const map = await new Promise((resolve) => { - shape.subscribe(resolve) + const rows = await new Promise((resolve) => { + shape.subscribe(({ rows }) => resolve(rows)) }) - const expectedValue = new Map() - expectedValue.set(`${issuesTableKey}/"${id}"`, { - id: id, - title: `test title`, - priority: 10, - }) - - expect(map).toEqual(expectedValue) - expect(shape.rowsSync).toEqual([ - { id: id, title: `test title`, priority: 10 }, - ]) + expect(rows).toEqual([{ id: id, title: `test title`, priority: 10 }]) expect(shape.lastSyncedAt()).toBeGreaterThanOrEqual(start) expect(shape.lastSyncedAt()).toBeLessThanOrEqual(Date.now()) expect(shape.lastSynced()).toBeLessThanOrEqual(Date.now() - start) @@ -63,26 +50,27 @@ describe(`Shape`, () => { insertIssues, deleteIssue, updateIssue, - issuesTableKey, aborter, }) => { const [id] = await insertIssues({ title: `test title` }) + const expectedValue1 = [ + { + id: id, + title: `test title`, + priority: 10, + }, + ] + const start = Date.now() const shapeStream = new ShapeStream({ url: `${BASE_URL}/v1/shape/${issuesTableUrl}`, signal: aborter.signal, }) const shape = new Shape(shapeStream) - const map = await shape.value + const rows = await shape.rows - const expectedValue = new Map() - expectedValue.set(`${issuesTableKey}/"${id}"`, { - id: id, - title: `test title`, - priority: 10, - }) - expect(map).toEqual(expectedValue) + expect(rows).toEqual(expectedValue1) expect(shape.lastSyncedAt()).toBeGreaterThanOrEqual(start) expect(shape.lastSyncedAt()).toBeLessThanOrEqual(Date.now()) expect(shape.lastSynced()).toBeLessThanOrEqual(Date.now() - start) @@ -103,12 +91,16 @@ describe(`Shape`, () => { await sleep(200) // some time for electric to catch up await hasNotified - expectedValue.set(`${issuesTableKey}/"${id2}"`, { - id: id2, - title: `new title`, - priority: 10, - }) - expect(shape.valueSync).toEqual(expectedValue) + const expectedValue2 = [ + ...expectedValue1, + { + id: id2, + title: `new title`, + priority: 10, + }, + ] + + expect(shape.currentRows).toEqual(expectedValue2) expect(shape.lastSyncedAt()).toBeGreaterThanOrEqual(intermediate) expect(shape.lastSyncedAt()).toBeLessThanOrEqual(Date.now()) expect(shape.lastSynced()).toBeLessThanOrEqual(Date.now() - intermediate) @@ -118,7 +110,6 @@ describe(`Shape`, () => { it(`should resync from scratch on a shape rotation`, async ({ issuesTableUrl, - issuesTableKey, insertIssues, deleteIssue, clearIssuesShape, @@ -128,19 +119,21 @@ describe(`Shape`, () => { const id2 = uuidv4() await insertIssues({ id: id1, title: `foo1` }) - const expectedValue1 = new Map() - expectedValue1.set(`${issuesTableKey}/"${id1}"`, { - id: id1, - title: `foo1`, - priority: 10, - }) + const expectedValue1 = [ + { + id: id1, + title: `foo1`, + priority: 10, + }, + ] - const expectedValue2 = new Map() - expectedValue2.set(`${issuesTableKey}/"${id2}"`, { - id: id2, - title: `foo2`, - priority: 10, - }) + const expectedValue2 = [ + { + id: id2, + title: `foo2`, + priority: 10, + }, + ] let requestsMade = 0 const start = Date.now() @@ -172,14 +165,14 @@ describe(`Shape`, () => { let dataUpdateCount = 0 await new Promise((resolve, reject) => { setTimeout(() => reject(`Timed out waiting for data changes`), 1000) - shape.subscribe((shapeData) => { + shape.subscribe(({ rows }) => { dataUpdateCount++ if (dataUpdateCount === 1) { - expect(shapeData).toEqual(expectedValue1) + expect(rows).toEqual(expectedValue1) expect(shape.lastSynced()).toBeLessThanOrEqual(Date.now() - start) return } else if (dataUpdateCount === 2) { - expect(shapeData).toEqual(expectedValue2) + expect(rows).toEqual(expectedValue2) expect(shape.lastSynced()).toBeLessThanOrEqual( Date.now() - rotationTime ) @@ -193,7 +186,6 @@ describe(`Shape`, () => { it(`should notify subscribers when the value changes`, async ({ issuesTableUrl, insertIssues, - issuesTableKey, aborter, }) => { const [id] = await insertIssues({ title: `test title` }) @@ -206,23 +198,24 @@ describe(`Shape`, () => { const shape = new Shape(shapeStream) const hasNotified = new Promise((resolve) => { - shape.subscribe(resolve) + shape.subscribe(({ rows }) => resolve(rows)) }) const [id2] = await insertIssues({ title: `other title` }) const value = await hasNotified - const expectedValue = new Map() - expectedValue.set(`${issuesTableKey}/"${id}"`, { - id: id, - title: `test title`, - priority: 10, - }) - expectedValue.set(`${issuesTableKey}/"${id2}"`, { - id: id2, - title: `other title`, - priority: 10, - }) + const expectedValue = [ + { + id: id, + title: `test title`, + priority: 10, + }, + { + id: id2, + title: `other title`, + priority: 10, + }, + ] expect(value).toEqual(expectedValue) expect(shape.lastSyncedAt()).toBeGreaterThanOrEqual(start) expect(shape.lastSyncedAt()).toBeLessThanOrEqual(Date.now()) @@ -257,7 +250,7 @@ describe(`Shape`, () => { expect(shapeStream.isConnected()).true const shape = new Shape(shapeStream) - await shape.value + await shape.rows expect(shapeStream.isConnected()).true diff --git a/packages/typescript-client/test/integration.test.ts b/packages/typescript-client/test/integration.test.ts index 2198f5a2be..a097035172 100644 --- a/packages/typescript-client/test/integration.test.ts +++ b/packages/typescript-client/test/integration.test.ts @@ -225,9 +225,9 @@ describe(`HTTP Sync`, () => { signal: aborter.signal, }) const client = new Shape(issueStream) - const data = await client.value + const rows = await client.rows - expect([...data.values()]).toMatchObject([ + expect(rows).toMatchObject([ { txt: `test`, i2: 1, @@ -300,9 +300,9 @@ describe(`HTTP Sync`, () => { const body = (await res.json()) as Message[] expect(body.length).greaterThan(1) }) - const updatedData = client.valueSync + const updatedData = await client.rows - expect([...updatedData.values()]).toMatchObject([ + expect(updatedData).toMatchObject([ { txt: `changed`, i2: 1, diff --git a/website/docs/api/clients/typescript.md b/website/docs/api/clients/typescript.md index 213825a264..5731b20f23 100644 --- a/website/docs/api/clients/typescript.md +++ b/website/docs/api/clients/typescript.md @@ -69,11 +69,11 @@ const stream = new ShapeStream({ const shape = new Shape(stream) // Returns promise that resolves with the latest shape data once it's fully loaded -await shape.value +await shape.rows // passes subscribers shape data when the shape updates -shape.subscribe(shapeData => { - // shapeData is a Map of the latest value of each row in a shape. +shape.subscribe(({ rows }) => { + // rows is an array of the latest value of each row in a shape. }) ``` diff --git a/website/docs/guides/shapes.md b/website/docs/guides/shapes.md index 9cf1d17cfc..f13e373f48 100644 --- a/website/docs/guides/shapes.md +++ b/website/docs/guides/shapes.md @@ -128,14 +128,14 @@ const stream = new ShapeStream({ const shape = new Shape(stream) // Returns promise that resolves with the latest shape data once it's fully loaded -await shape.value +await shape.rows ``` You can register a callback to be notified whenever the shape data changes: ```ts -shape.subscribe(shapeData => { - // shapeData is a Map of the latest value of each row in a shape. +shape.subscribe(({ rows }) => { + // rows is an array of the latest value of each row in a shape. }) ``` From e267886c16e9919c6a4cbf5b95119d40602d4ece Mon Sep 17 00:00:00 2001 From: msfstef Date: Tue, 29 Oct 2024 15:58:48 +0200 Subject: [PATCH 5/6] Update changeset --- .changeset/friendly-toes-check.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/friendly-toes-check.md b/.changeset/friendly-toes-check.md index 035dc75f73..88f6fcabcc 100644 --- a/.changeset/friendly-toes-check.md +++ b/.changeset/friendly-toes-check.md @@ -1,6 +1,8 @@ --- -"@electric-sql/client": patch +"@electric-sql/client": minor "@electric-sql/react": patch --- -Implement `rows` and `rowsSync` getters on `Shape` interface for easier data access. +- Implement `rows` and `currentRows` getters on `Shape` interface for easier data access. +- [BREAKING] Rename `valueSync` getter on `Shape` to `currentValue` for clarity and consistency. +- [BREAKING] Change `subscribe` API on `Shape` to accept callbacks with signature `({ rows: T[], value: Map }) => void` From a31182581032c97dd5a3ddd7a7e323afb0c0522a Mon Sep 17 00:00:00 2001 From: msfstef Date: Tue, 29 Oct 2024 16:04:41 +0200 Subject: [PATCH 6/6] Fix type issue --- packages/react-hooks/src/react-hooks.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-hooks/src/react-hooks.tsx b/packages/react-hooks/src/react-hooks.tsx index 5928616b72..4a73c4f4e0 100644 --- a/packages/react-hooks/src/react-hooks.tsx +++ b/packages/react-hooks/src/react-hooks.tsx @@ -96,7 +96,7 @@ function parseShapeData>( shape: Shape ): UseShapeResult { return { - data: shape.rowsSync, + data: shape.currentRows, isLoading: shape.isLoading(), lastSyncedAt: shape.lastSyncedAt(), isError: shape.error !== false,