diff --git a/.changeset/heavy-hats-invite.md b/.changeset/heavy-hats-invite.md new file mode 100644 index 00000000..49bd8341 --- /dev/null +++ b/.changeset/heavy-hats-invite.md @@ -0,0 +1,5 @@ +--- +'@verdant-web/store': patch +--- + +Subscribe to individual fields on entities diff --git a/packages/store/src/__tests__/documents.test.ts b/packages/store/src/__tests__/entities.test.ts similarity index 92% rename from packages/store/src/__tests__/documents.test.ts rename to packages/store/src/__tests__/entities.test.ts index d8f45fd9..9a4ca410 100644 --- a/packages/store/src/__tests__/documents.test.ts +++ b/packages/store/src/__tests__/entities.test.ts @@ -20,7 +20,7 @@ async function waitForStoragePropagation(mock: MockedFunction) { }); } -describe('storage documents', () => { +describe('entities', () => { it('should fill in default values', async () => { const storage = await createTestStorage(); @@ -64,6 +64,7 @@ describe('storage documents', () => { const singleItemResult = await singleItemQuery.resolved; expect(singleItemResult).toBeTruthy(); assert(!!singleItemResult); + expect(singleItemResult).toBe(item1); singleItemResult.subscribe('change', vi.fn()); const allItemsQuery = storage.todos.findAll(); @@ -637,6 +638,47 @@ describe('storage documents', () => { storage.todos.put({ content: { invalid: 'value' }, }); - }).toThrowErrorMatchingInlineSnapshot(`[Error: Validation error: Expected string for field content, got [object Object]]`); + }).toThrowErrorMatchingInlineSnapshot( + `[Error: Validation error: Expected string for field content, got [object Object]]`, + ); + }); + + it('should allow subscribing to one field', async () => { + const storage = await createTestStorage(); + const item = await storage.todos.put({ + content: 'item', + }); + const queriedItem = await storage.todos.get(item.get('id')).resolved; + + const callback = vi.fn(); + queriedItem.subscribeToField('content', 'change', callback); + + queriedItem.set('content', 'updated'); + + await waitForStoragePropagation(callback); + + expect(callback).toBeCalledTimes(1); + }); + + it('should allow subscribing to one field and not break on deletion', async () => { + const storage = await createTestStorage({ + log: console.log, + }); + const item = await storage.todos.put({ + content: 'item', + }); + + const callback = vi.fn(console.log); + item.subscribeToField('content', 'change', callback); + + item.set('content', 'updated'); + + await waitForStoragePropagation(callback); + + expect(callback).toBeCalledTimes(1); + + await storage.todos.delete(item.get('id')); + + expect(item.deleted).toBe(true); }); }); diff --git a/packages/store/src/__tests__/fixtures/testStorage.ts b/packages/store/src/__tests__/fixtures/testStorage.ts index 9a30a94b..f3f2d521 100644 --- a/packages/store/src/__tests__/fixtures/testStorage.ts +++ b/packages/store/src/__tests__/fixtures/testStorage.ts @@ -24,7 +24,9 @@ export const todoCollection = schema.collection({ tags: schema.fields.array({ items: schema.fields.string(), }), - category: schema.fields.string(), + category: schema.fields.string({ + nullable: true, + }), attachments: schema.fields.array({ items: schema.fields.object({ properties: { diff --git a/packages/store/src/entities/Entity.test.ts b/packages/store/src/entities/Entity.test.ts index 3d5756dd..ecaf78b3 100644 --- a/packages/store/src/entities/Entity.test.ts +++ b/packages/store/src/entities/Entity.test.ts @@ -69,7 +69,7 @@ describe('Entity', () => { oid: 'test/1', schema, ctx: mockContext, - events, + storeEvents: events, metadataFamily: new EntityFamilyMetadata({ ctx: mockContext, onPendingOperations, diff --git a/packages/store/src/entities/Entity.ts b/packages/store/src/entities/Entity.ts index 59bd512c..c310b5e5 100644 --- a/packages/store/src/entities/Entity.ts +++ b/packages/store/src/entities/Entity.ts @@ -45,6 +45,7 @@ import { ObjectEntity, } from './types.js'; import { EntityStoreEventData, EntityStoreEvents } from './EntityStore.js'; +import { entityFieldSubscriber } from './entityFieldSubscriber.js'; export interface EntityInit { oid: ObjectIdentifier; @@ -57,7 +58,7 @@ export interface EntityInit { readonlyKeys?: string[]; fieldPath?: (string | number)[]; patchCreator: PatchCreator; - events: EntityStoreEvents; + storeEvents: EntityStoreEvents; deleteSelf: () => void; } @@ -83,7 +84,7 @@ export class Entity< private ctx; private files; private patchCreator; - private events; + private storeEvents; // an internal representation of this Entity. // if present, this is the cached, known value. If null, @@ -109,7 +110,7 @@ export class Entity< readonlyKeys, files, patchCreator, - events, + storeEvents, deleteSelf, }: EntityInit) { super(); @@ -128,15 +129,15 @@ export class Entity< }); this.patchCreator = patchCreator; this.metadataFamily = metadataFamily; - this.events = events; + this.storeEvents = storeEvents; this.parent = parent; this._deleteSelf = deleteSelf; // TODO: should any but the root entity be listening to these? if (!this.parent) { - events.add.attach(this.onAdd); - events.replace.attach(this.onReplace); - events.resetAll.attach(this.onResetAll); + storeEvents.add.attach(this.onAdd); + storeEvents.replace.attach(this.onReplace); + storeEvents.resetAll.attach(this.onResetAll); } } @@ -563,11 +564,22 @@ export class Entity< files: this.files, fieldPath: [...this.fieldPath, key], patchCreator: this.patchCreator, - events: this.events, + storeEvents: this.storeEvents, deleteSelf: this.delete.bind(this, key), }); }; + subscribeToField = ( + key: K, + event: 'change', // here to keep future api changes stable + callback: ( + value: KeyValue[K], + info: { previousValue: KeyValue[K]; isLocal?: boolean }, + ) => void, + ) => { + return entityFieldSubscriber(this, key, callback); + }; + // generic entity methods /** * Gets a value from this Entity. If the value diff --git a/packages/store/src/entities/EntityMetadata.ts b/packages/store/src/entities/EntityMetadata.ts index b2793640..d577ab84 100644 --- a/packages/store/src/entities/EntityMetadata.ts +++ b/packages/store/src/entities/EntityMetadata.ts @@ -19,6 +19,7 @@ export type EntityMetadataView = { deleted: boolean; empty: boolean; updatedAt: number; + latestTimestamp: string | null; }; export class EntityMetadata { @@ -136,6 +137,7 @@ export class EntityMetadata { empty, fromOlderVersion, updatedAt, + latestTimestamp: updatedAtTimestamp, }; }; diff --git a/packages/store/src/entities/EntityStore.ts b/packages/store/src/entities/EntityStore.ts index cd4807b5..1da21d7b 100644 --- a/packages/store/src/entities/EntityStore.ts +++ b/packages/store/src/entities/EntityStore.ts @@ -451,7 +451,7 @@ export class EntityStore extends Disposable { files: this.files, metadataFamily: metadataFamily, patchCreator: this.meta.patchCreator, - events: this.events, + storeEvents: this.events, deleteSelf: this.delete.bind(this, oid), }); }; diff --git a/packages/store/src/entities/entityFieldSubscriber.ts b/packages/store/src/entities/entityFieldSubscriber.ts new file mode 100644 index 00000000..3dce45f0 --- /dev/null +++ b/packages/store/src/entities/entityFieldSubscriber.ts @@ -0,0 +1,31 @@ +import { Entity } from './Entity.js'; +import { EntityChangeInfo } from './types.js'; + +export function entityFieldSubscriber( + entity: Entity, + field: string | number | symbol, + subscriber: ( + newValue: T, + info: EntityChangeInfo & { previousValue: T }, + ) => void, +) { + const valueHolder = { + previousValue: entity.get(field), + isLocal: false, + }; + function handler( + this: { previousValue: T } & EntityChangeInfo, + info: EntityChangeInfo, + ) { + if (entity.deleted) { + return; + } + const newValue = entity.get(field); + if (newValue !== this.previousValue) { + this.isLocal = info.isLocal; + subscriber(newValue, this); + this.previousValue = newValue; + } + } + return entity.subscribe('change', handler.bind(valueHolder)); +}