diff --git a/package.json b/package.json index 00842545..f20c73f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.9.78", + "version": "0.9.79", "private": true, "scripts": { "build": "pnpm -r run build", diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json index fea62303..58850c04 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.9.78", + "version": "0.9.79", "description": "Cross-agent architecture skills for @adobe/data — installable as a Claude Code plugin or copied into any Agent-Skills-compatible agent (Cursor, Codex).", "type": "module", "private": false, diff --git a/packages/data-gpu-samples/package.json b/packages/data-gpu-samples/package.json index 207f4c12..209809c6 100644 --- a/packages/data-gpu-samples/package.json +++ b/packages/data-gpu-samples/package.json @@ -1,6 +1,6 @@ { "name": "data-gpu-samples", - "version": "0.9.78", + "version": "0.9.79", "description": "WebGPU samples built on @adobe/data-gpu", "type": "module", "private": true, diff --git a/packages/data-gpu/package.json b/packages/data-gpu/package.json index 6c70f43b..217be77e 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.9.78", + "version": "0.9.79", "description": "Adobe data WebGPU plugins and types for graphics and compute", "type": "module", "private": false, diff --git a/packages/data-lit-tictactoe/package.json b/packages/data-lit-tictactoe/package.json index 860a58c7..850ad4fe 100644 --- a/packages/data-lit-tictactoe/package.json +++ b/packages/data-lit-tictactoe/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-tictactoe", - "version": "0.9.78", + "version": "0.9.79", "description": "Tic-Tac-Toe sample - Lit web components with @adobe/data-lit and AgenticService", "type": "module", "private": true, diff --git a/packages/data-lit-todo/package.json b/packages/data-lit-todo/package.json index 935046eb..03364236 100644 --- a/packages/data-lit-todo/package.json +++ b/packages/data-lit-todo/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-todo", - "version": "0.9.78", + "version": "0.9.79", "description": "Todo sample app demonstrating @adobe/data with Lit", "type": "module", "private": true, diff --git a/packages/data-lit/package.json b/packages/data-lit/package.json index 516f87e8..5300f708 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.9.78", + "version": "0.9.79", "description": "Adobe data Lit bindings - hooks, elements, decorators", "type": "module", "private": false, diff --git a/packages/data-p2p-tictactoe/package.json b/packages/data-p2p-tictactoe/package.json index 3eaf5515..6f0c5ac4 100644 --- a/packages/data-p2p-tictactoe/package.json +++ b/packages/data-p2p-tictactoe/package.json @@ -1,6 +1,6 @@ { "name": "data-p2p-tictactoe", - "version": "0.9.78", + "version": "0.9.79", "description": "Serverless P2P tic-tac-toe — WebRTC DataChannel + @adobe/data-sync", "type": "module", "private": true, diff --git a/packages/data-persistence/package.json b/packages/data-persistence/package.json index 28f86716..54e53bf6 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.9.78", + "version": "0.9.79", "description": "Worker-based incremental persistence layer for @adobe/data ECS over OPFS (browser) and node:fs (server).", "type": "module", "sideEffects": false, diff --git a/packages/data-react-hello/package.json b/packages/data-react-hello/package.json index 64fcead5..d236e0bd 100644 --- a/packages/data-react-hello/package.json +++ b/packages/data-react-hello/package.json @@ -1,6 +1,6 @@ { "name": "data-react-hello", - "version": "0.9.78", + "version": "0.9.79", "description": "Hello World sample - click counter using @adobe/data-react", "type": "module", "private": true, diff --git a/packages/data-react-pixie/package.json b/packages/data-react-pixie/package.json index 433cd9a8..e71020b3 100644 --- a/packages/data-react-pixie/package.json +++ b/packages/data-react-pixie/package.json @@ -1,6 +1,6 @@ { "name": "data-react-pixie", - "version": "0.9.78", + "version": "0.9.79", "description": "PixiJS React sample - ECS sprites (bunny, fox) with @adobe/data-react", "type": "module", "private": true, diff --git a/packages/data-react/package.json b/packages/data-react/package.json index 519137dd..205aa0f5 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.9.78", + "version": "0.9.79", "description": "Adobe data React bindings — hooks and context for ECS database", "type": "module", "private": false, diff --git a/packages/data-solid-dashboard/package.json b/packages/data-solid-dashboard/package.json index 09f8b163..62b3e662 100644 --- a/packages/data-solid-dashboard/package.json +++ b/packages/data-solid-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "data-solid-dashboard", - "version": "0.9.78", + "version": "0.9.79", "description": "Mini dashboard sample — multiple components sharing one @adobe/data ECS database with SolidJS", "type": "module", "private": true, diff --git a/packages/data-solid/package.json b/packages/data-solid/package.json index 4d70b0c2..ae2cfff9 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.9.78", + "version": "0.9.79", "description": "Adobe data SolidJS bindings — context and provider for ECS database", "type": "module", "private": false, diff --git a/packages/data-sync/package.json b/packages/data-sync/package.json index 6778635b..f328cebd 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.9.78", + "version": "0.9.79", "description": "Multi-user real-time synchronisation for @adobe/data ECS — server, client, and in-process loopback.", "type": "module", "sideEffects": false, diff --git a/packages/data/package.json b/packages/data/package.json index d8561241..ae38e2c9 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.9.78", + "version": "0.9.79", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, diff --git a/packages/data/src/ecs/database/observe-select-entities.test.ts b/packages/data/src/ecs/database/observe-select-entities.test.ts index 599d6b7e..2454f347 100644 --- a/packages/data/src/ecs/database/observe-select-entities.test.ts +++ b/packages/data/src/ecs/database/observe-select-entities.test.ts @@ -312,6 +312,125 @@ describe("observeSelectEntities", () => { unsubscribe(); }); + it("should notify when a required component is removed, dropping the entity from the result set", async () => { + const observer = vi.fn(); + // Select on position+health but NOT filtered/ordered on either. This is the + // membership-contraction case: removing `health` migrates the row to the + // position-only archetype, so it leaves the result set even though the + // caller never filters or orders on health. + const unsubscribe = database.observe.select(["position", "health"])(observer); + + expect(observer).toHaveBeenCalledTimes(1); + expect(observer.mock.calls[0][0]).toContain(entities.posHealth1); + + // Remove the `health` component (undefined => column removal) so the entity + // migrates out of the position+health archetype. + database.transactions.updateEntity({ + entity: entities.posHealth1, + values: { health: undefined } + }); + + await Promise.resolve(); + + // Must re-emit without the entity that left the result set. + expect(observer).toHaveBeenCalledTimes(2); + expect(observer.mock.calls[1][0]).not.toContain(entities.posHealth1); + + unsubscribe(); + }); + + it("should NOT notify when removing a component the query does not select", async () => { + const observer = vi.fn(); + // Selecting only position: removing `health` from a position+health entity + // keeps it a member (its archetype is still a superset of {position}), so + // no re-emit should occur — the O(changes) early-exit must still fire. + const unsubscribe = database.observe.select(["position"])(observer); + + expect(observer).toHaveBeenCalledTimes(1); + expect(observer.mock.calls[0][0]).toContain(entities.posHealth1); + + database.transactions.updateEntity({ + entity: entities.posHealth1, + values: { health: undefined } + }); + + await Promise.resolve(); + + // Still a member => no new notification. + expect(observer).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); + + it("should drop an entity that gains an excluded component", async () => { + const observer = vi.fn(); + // Select position, excluding health. posName entities qualify; adding health + // to one must remove it from the result set even though `health` is neither + // an include nor a where/order key. + const unsubscribe = database.observe.select(["position"], { exclude: ["health"] })(observer); + + expect(observer).toHaveBeenCalledTimes(1); + expect(observer.mock.calls[0][0]).toContain(entities.posName1); + expect(observer.mock.calls[0][0]).not.toContain(entities.posHealth1); + + database.transactions.updateEntity({ + entity: entities.posName1, + values: { health: 50 } + }); + + await Promise.resolve(); + + expect(observer).toHaveBeenCalledTimes(2); + expect(observer.mock.calls[1][0]).not.toContain(entities.posName1); + + unsubscribe(); + }); + + it("should add an entity that loses an excluded component", async () => { + const observer = vi.fn(); + const unsubscribe = database.observe.select(["position"], { exclude: ["health"] })(observer); + + expect(observer).toHaveBeenCalledTimes(1); + expect(observer.mock.calls[0][0]).not.toContain(entities.posHealth1); + + // Remove health => posHealth1 becomes a position-only entity, now qualifying. + database.transactions.updateEntity({ + entity: entities.posHealth1, + values: { health: undefined } + }); + + await Promise.resolve(); + + expect(observer).toHaveBeenCalledTimes(2); + expect(observer.mock.calls[1][0]).toContain(entities.posHealth1); + + unsubscribe(); + }); + + it("should add an entity that migrates into a not-yet-seen archetype", async () => { + const observer = vi.fn(); + // Select position+score. Initially only Full entities qualify. Give a + // position-only entity a score, migrating it into a brand-new archetype + // (position+score) that did not exist when the subscription was created — + // the memoized classifier must recognise it on first sight. + const unsubscribe = database.observe.select(["position", "score"])(observer); + + expect(observer).toHaveBeenCalledTimes(1); + expect(observer.mock.calls[0][0]).not.toContain(entities.pos1); + + database.transactions.updateEntity({ + entity: entities.pos1, + values: { score: 42 } + }); + + await Promise.resolve(); + + expect(observer).toHaveBeenCalledTimes(2); + expect(observer.mock.calls[1][0]).toContain(entities.pos1); + + unsubscribe(); + }); + it("should handle entity updates", async () => { const observer = vi.fn(); const unsubscribe = database.observe.select(["position", "health"])(observer); diff --git a/packages/data/src/ecs/database/observe-select-entities.ts b/packages/data/src/ecs/database/observe-select-entities.ts index 7727e016..e2f178ce 100644 --- a/packages/data/src/ecs/database/observe-select-entities.ts +++ b/packages/data/src/ecs/database/observe-select-entities.ts @@ -5,6 +5,7 @@ import { getRowPredicateFromFilter } from "../../table/select-rows.js"; import { StringKeyof } from "../../types/types.js"; import { RequiredComponents } from "../required-components.js"; import { Entity } from "../entity/entity.js"; +import { ArchetypeId, ReadonlyArchetype } from "../archetype/index.js"; import { OptionalComponents, ReadonlyStore } from "../index.js"; import { EntitySelectOptions } from "../store/entity-select-options.js"; import { TransactionResult } from "./transactional-store/transactional-store.js"; @@ -18,11 +19,37 @@ export const observeSelectEntities = (store: ReadonlyStore => { return (observer: (entities: readonly Entity[]) => void) => { const includeSet = new Set(include); + const excludeSet = new Set(options?.exclude ?? []); const whereSet = new Set(Object.keys(options?.where ?? {}) as StringKeyof[]); const orderSet = new Set(Object.keys(options?.order ?? {}) as StringKeyof[]); let isMicrotaskQueued = false; let currentEntities: Set | null = null; const rowPredicate = getRowPredicateFromFilter(options?.where); + // Callers only reach this after `qualifies` has confirmed the archetype's + // components are a superset of `include`, and `where` keys are a subset of + // `include`, so the archetype carries every column the predicate reads. + // `store.locate` only types the base `id` column, so assert the fuller table + // shape the predicate expects (narrower than `as any`). + const matchesFilter = (archetype: ReadonlyArchetype, row: number) => + rowPredicate(archetype as Parameters[0], row); + + // Whether an archetype qualifies for this query — its components are a + // superset of `include` and disjoint from `exclude` — is a pure function of + // the archetype's component set, which never changes once created, and + // archetype ids are stable and densely assigned. Memoize the verdict by id + // so the per-entity membership test is an O(1) map lookup rather than an + // O(|include|) superset scan: each archetype is classified at most once per + // subscription, and archetypes created later are classified on first sight. + const archetypeQualifies = new Map(); + const qualifies = (archetype: ReadonlyArchetype): boolean => { + let verdict = archetypeQualifies.get(archetype.id); + if (verdict === undefined) { + verdict = archetype.components.isSupersetOf(includeSet) + && (excludeSet.size === 0 || archetype.components.isDisjointFrom(excludeSet)); + archetypeQualifies.set(archetype.id, verdict); + } + return verdict; + }; const notifyObsever = () => { // we just do a full select here. @@ -34,52 +61,68 @@ export const observeSelectEntities = (store: ReadonlyStore { - if (t.changedComponents.isDisjointFrom(includeSet)) { - // no components in the changed set are in the include set - // so we don't need to notify the observer. there is no possible change. + if (t.changedComponents.isDisjointFrom(includeSet) && t.changedComponents.isDisjointFrom(excludeSet)) { + // No changed component is selected or excluded. An entity can only + // enter or leave the result set by gaining/losing an `include` + // component or gaining/losing an `exclude` component, and `order`/ + // `where` keys are a subset of `include`, so neither the membership, + // the ordering, nor the filter outcome can have changed. (When there + // is no exclude clause `excludeSet` is empty and this extra test is a + // free O(1) `isDisjointFrom(∅)`.) return; } if (currentEntities) { let needsUpdate = false; for (const entity of t.changedEntities.keys()) { - if (currentEntities.has(entity)) { - const changedEntityValues = t.changedEntities.get(entity); - if (!changedEntityValues) { - // entity delete => update + const inSet = currentEntities.has(entity); + const changedEntityValues = t.changedEntities.get(entity); + if (!changedEntityValues) { + // hard delete: only matters if the entity was in the set. + if (inSet) { needsUpdate = true; break; } - const changedComponentSet = new Set(Object.keys(changedEntityValues) as StringKeyof[]); - if (!changedComponentSet.isDisjointFrom(orderSet)) { - // entity order components changed => update + continue; + } + const changedComponentSet = new Set(Object.keys(changedEntityValues) as StringKeyof[]); + if (inSet && !changedComponentSet.isDisjointFrom(orderSet)) { + // An order-key changed for a member. Whether the row stays a + // member (its sort value moved) or left the set, the result must + // be requeried — so short-circuit before the otherwise-needless + // locate. (`order` keys are a subset of `include`, so for a + // non-member this same change is instead an entry candidate, + // handled by the membership test below.) + needsUpdate = true; + break; + } + if (includeSet.isDisjointFrom(changedComponentSet) && excludeSet.isDisjointFrom(changedComponentSet)) { + // No selected or excluded component changed for this entity, so + // its membership is unchanged and — for an in-set row — its + // filter value is unchanged too (`where` keys are a subset of + // `include`). + continue; + } + // A selected or excluded component changed for this entity; re-derive + // its membership from the authoritative store: one O(1) locate plus a + // memoized O(1) qualification lookup, no scan of the result set. + const location = store.locate(entity); + if (inSet) { + if (location === null || !qualifies(location.archetype)) { + // entity left the result set (deleted or migrated out — + // e.g. a required component was removed). needsUpdate = true; break; } - if (!changedComponentSet.isDisjointFrom(whereSet)) { - // some where components were changed, - // we will see if the entity is still in the result set - const { archetype, row } = store.locate(entity)!; - if (!rowPredicate(archetype as any, row)) { - // entity is no longer in the result set - needsUpdate = true; - break; - } - } - } - else { - // this entity is not in the set, we need to check it would be added to the result set. - const location = store.locate(entity); - if (location) { - const { archetype, row } = location; - if (archetype.components.isSupersetOf(includeSet)) { - if (rowPredicate(archetype as any, row)) { - // this entity would be in the result set. - needsUpdate = true; - break; - } - } + if (!changedComponentSet.isDisjointFrom(whereSet) && !matchesFilter(location.archetype, location.row)) { + // a filter value changed and the row no longer matches. + needsUpdate = true; + break; } + } else if (location !== null && qualifies(location.archetype) && matchesFilter(location.archetype, location.row)) { + // entity entered the result set. + needsUpdate = true; + break; } } if (!needsUpdate) {