Skip to content

Commit

Permalink
subscribe to single field
Browse files Browse the repository at this point in the history
  • Loading branch information
a-type committed Jun 30, 2024
1 parent 8ac7517 commit d11e3ab
Show file tree
Hide file tree
Showing 8 changed files with 107 additions and 13 deletions.
5 changes: 5 additions & 0 deletions .changeset/heavy-hats-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@verdant-web/store': patch
---

Subscribe to individual fields on entities
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ async function waitForStoragePropagation(mock: MockedFunction<any>) {
});
}

describe('storage documents', () => {
describe('entities', () => {
it('should fill in default values', async () => {
const storage = await createTestStorage();

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
});
});
4 changes: 3 additions & 1 deletion packages/store/src/__tests__/fixtures/testStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion packages/store/src/entities/Entity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ describe('Entity', () => {
oid: 'test/1',
schema,
ctx: mockContext,
events,
storeEvents: events,
metadataFamily: new EntityFamilyMetadata({
ctx: mockContext,
onPendingOperations,
Expand Down
28 changes: 20 additions & 8 deletions packages/store/src/entities/Entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -57,7 +58,7 @@ export interface EntityInit {
readonlyKeys?: string[];
fieldPath?: (string | number)[];
patchCreator: PatchCreator;
events: EntityStoreEvents;
storeEvents: EntityStoreEvents;
deleteSelf: () => void;
}

Expand All @@ -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,
Expand All @@ -109,7 +110,7 @@ export class Entity<
readonlyKeys,
files,
patchCreator,
events,
storeEvents,
deleteSelf,
}: EntityInit) {
super();
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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 = <K extends keyof KeyValue>(
key: K,
event: 'change', // here to keep future api changes stable
callback: (
value: KeyValue[K],
info: { previousValue: KeyValue[K]; isLocal?: boolean },
) => void,
) => {
return entityFieldSubscriber<KeyValue[K]>(this, key, callback);
};

// generic entity methods
/**
* Gets a value from this Entity. If the value
Expand Down
2 changes: 2 additions & 0 deletions packages/store/src/entities/EntityMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type EntityMetadataView = {
deleted: boolean;
empty: boolean;
updatedAt: number;
latestTimestamp: string | null;
};

export class EntityMetadata {
Expand Down Expand Up @@ -136,6 +137,7 @@ export class EntityMetadata {
empty,
fromOlderVersion,
updatedAt,
latestTimestamp: updatedAtTimestamp,
};
};

Expand Down
2 changes: 1 addition & 1 deletion packages/store/src/entities/EntityStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
};
Expand Down
31 changes: 31 additions & 0 deletions packages/store/src/entities/entityFieldSubscriber.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Entity } from './Entity.js';
import { EntityChangeInfo } from './types.js';

export function entityFieldSubscriber<T = any>(
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));
}

0 comments on commit d11e3ab

Please sign in to comment.