diff --git a/CHANGELOG.md b/CHANGELOG.md index 462f52a0..d0a6cfdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- V18 query reads now route linear node property reads, edge property reads, + and edge-list property payloads through projection-backed compatibility + records instead of decoding raw property keys in the query controller. +- V18 legacy property projection now exposes `EdgePropertyProjection`, a + deterministic read projection from visible `WarpState` edge properties to + runtime-backed compatibility records with edge-birth filtering. +- V18 legacy property projection now exposes `NodePropertyProjection`, a + deterministic read projection from visible `WarpState` node properties to + runtime-backed compatibility records. +- V18 legacy property projection now exposes runtime-backed node property key, + edge property key, property value, visible node property record, visible + edge property record, and projection collection nouns. - V18 planning now records detailed design documents for slices 26 through 45, covering legacy property projection, property write intents, graph-model migration dry-run tooling, genesis replay equivalence, and the next @@ -58,6 +70,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- V18 property projection review follow-up now preserves tolerant public + property-query misses, scopes targeted projection materialization to requested + owners, skips malformed edge-property projection entries, shares legacy + content key constants, and rejects class-instance property carriers. - V18 content attachment projection now preserves existing same-patch metadata lineage semantics when `_content`, `_content.mime`, and `_content.size` are separate operations in one patch. diff --git a/docs/BEARING.md b/docs/BEARING.md index 52b7e5d0..b3fc5b76 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -39,14 +39,14 @@ of handwritten adapter folklore. Current branch state at this boundary: -- Branch: `v18-continuum-slices-26-45-design` +- Branch: `v18-continuum-slices-26-30` - Base branch: `main` -- Current `origin/main`: `e288c113` -- Latest merged PR: #97, v18 Continuum slices 21 through 25 +- Current `origin/main`: `c66f1a49` +- Latest merged PR: #98, v18 design documents for slices 26 through 45 - Latest released package line: `17.0.1` - Latest completed implementation cycle: - `0173-v18-content-write-intent-cutover` -- Current work: documentation-only planning for slices 26 through 45 + `0178-v18-query-property-projection-reads` +- Current work: implementation branch `v18-continuum-slices-26-30` The current v18 graph-model posture is: @@ -79,10 +79,14 @@ PRs #94 through #96 had already landed the earlier v18 evidence posture, generated-family readiness, runtime-boundary source facts, node and edge records, generic attachment substrate, and graph-op algebra groundwork. +PR #98 landed the detailed design documents for slices 26 through 45 and +reset this bearing around the property-projection, migration dry-run, and +genesis-equivalence runway. + ## What Feels Wrong -- Property reads still have direct raw legacy property interpretation in - places such as query reads and state-reader context code. +- Some non-query read surfaces still have direct raw legacy property + interpretation, especially state-reader context code. - Generic property writes still lower directly to legacy property operations; content writes are intent-backed, but property writes are not. - Content persistence still uses legacy `_content*` compatibility properties. @@ -160,15 +164,15 @@ and concrete checks live in `docs/invariants/`. - [x] 23. Add content attachment projection. - [x] 24. Route public content reads through content projection. - [x] 25. Route content writes through typed write intent. -- [ ] 26. Reset the post-25 property projection runway: +- [x] 26. Reset the post-25 property projection runway: [0174](design/0174-v18-post-25-property-projection-runway/v18-post-25-property-projection-runway.md). -- [ ] 27. Add legacy property projection nouns: +- [x] 27. Add legacy property projection nouns: [0175](design/0175-v18-legacy-property-projection-nouns/v18-legacy-property-projection-nouns.md). -- [ ] 28. Add node property projection: +- [x] 28. Add node property projection: [0176](design/0176-v18-node-property-projection/v18-node-property-projection.md). -- [ ] 29. Add edge property projection: +- [x] 29. Add edge property projection: [0177](design/0177-v18-edge-property-projection/v18-edge-property-projection.md). -- [ ] 30. Route query property reads through projection: +- [x] 30. Route query property reads through projection: [0178](design/0178-v18-query-property-projection-reads/v18-query-property-projection-reads.md). - [ ] 31. Route state-reader property views through projection: [0179](design/0179-v18-state-reader-property-projection/v18-state-reader-property-projection.md). diff --git a/docs/design/0174-v18-post-25-property-projection-runway/v18-post-25-property-projection-runway.md b/docs/design/0174-v18-post-25-property-projection-runway/v18-post-25-property-projection-runway.md index aa7f4060..1cef74c3 100644 --- a/docs/design/0174-v18-post-25-property-projection-runway/v18-post-25-property-projection-runway.md +++ b/docs/design/0174-v18-post-25-property-projection-runway/v18-post-25-property-projection-runway.md @@ -1,11 +1,12 @@ --- cycle: 0174 task_id: V18_post_25_property_projection_runway -status: Planned +status: Complete sponsors: human: James agent: Codex started_at: 2026-05-23 +completed_at: 2026-05-23 release_home: v18.0.0 bearing_task: 26 promotes_backlog: @@ -84,8 +85,8 @@ work needed to make that claim later. ## RED Plan -No regression test is required for the documentation-only slice. The failure -condition is stale or missing planning evidence: +No regression test was required for the documentation-only slice. The failure +condition was stale or missing planning evidence: - `BEARING.md` still describes the pre-PR-97 branch; - slices 26 through 45 are not all represented by design documents; @@ -94,9 +95,9 @@ condition is stale or missing planning evidence: ## GREEN Plan -Add one design file for each slice from 26 through 45. Rewrite `BEARING.md` -around the current main-line state, the merged slice-21-through-25 work, and -the next twenty planned moves. +One design file now exists for each slice from 26 through 45. `BEARING.md` +was rewritten around the current main-line state, the merged +slice-21-through-25 work, and the next twenty planned moves. The text must describe property bags as compatibility views and graph attachments as the emerging substrate. It must also preserve the honest @@ -110,9 +111,18 @@ npx markdownlint-cli2 CHANGELOG.md docs/BEARING.md docs/design/0174-v18-post-25- git diff --check HEAD ``` +Observed on closeout: + +```text +npx markdownlint-cli2 CHANGELOG.md docs/BEARING.md docs/design/0174-v18-post-25-property-projection-runway/v18-post-25-property-projection-runway.md +Summary: 0 error(s) + +git diff --check HEAD +``` + ## Closeout Criteria -- `BEARING.md` names the current merged PR #97 state. +- `BEARING.md` names the current merged PR #98 state. - Slices 26 through 45 are listed and linked. - Each planned slice has a design document. - The changelog records the planning update under `Unreleased`. diff --git a/docs/design/0175-v18-legacy-property-projection-nouns/v18-legacy-property-projection-nouns.md b/docs/design/0175-v18-legacy-property-projection-nouns/v18-legacy-property-projection-nouns.md index ab68d4de..3f5e4183 100644 --- a/docs/design/0175-v18-legacy-property-projection-nouns/v18-legacy-property-projection-nouns.md +++ b/docs/design/0175-v18-legacy-property-projection-nouns/v18-legacy-property-projection-nouns.md @@ -1,11 +1,12 @@ --- cycle: 0175 task_id: V18_legacy_property_projection_nouns -status: Planned +status: Complete sponsors: human: James agent: Codex started_at: 2026-05-23 +completed_at: 2026-05-23 release_home: v18.0.0 bearing_task: 27 promotes_backlog: @@ -80,7 +81,7 @@ hide or preserve them for the public property API. ## RED Plan -Add tests that fail because no runtime-backed projection nouns exist: +Added tests that failed because no runtime-backed projection nouns existed: - constructing a node property record from a malformed node id fails; - constructing an edge property record from malformed edge coordinates fails; @@ -88,14 +89,21 @@ Add tests that fail because no runtime-backed projection nouns exist: - property records expose owner, key, and value without exposing mutable carrier objects. +Observed RED: + +```text +npx vitest run test/unit/domain/graph/LegacyPropertyProjection.test.ts --reporter=verbose +Error: Cannot find module '../../../../src/domain/graph/LegacyEdgePropertyKey.ts' +``` + ## GREEN Plan -Create one file per concept. Constructors validate the smallest possible +Created one file per concept. Constructors validate the smallest possible runtime boundary and freeze instances. Dispatch uses `instanceof` where the code needs to distinguish node and edge property records. -Export the nouns through the graph substrate public surface only after tests -prove the intended construction and read behavior. +The nouns are exported through the graph substrate public surface after tests +proved the intended construction and read behavior. ## Verification @@ -107,6 +115,19 @@ npm run lint:sludge git diff --check HEAD ``` +Observed GREEN: + +```text +npx vitest run test/unit/domain/graph/LegacyPropertyProjection.test.ts --reporter=verbose +Test Files 1 passed (1) +Tests 5 passed (5) + +npx eslint src/domain/graph/LegacyPropertyKeyClassification.ts src/domain/graph/LegacyNodePropertyKey.ts src/domain/graph/LegacyEdgePropertyKey.ts src/domain/graph/LegacyPropertyValue.ts src/domain/graph/VisibleNodePropertyRecord.ts src/domain/graph/VisibleEdgePropertyRecord.ts src/domain/graph/LegacyPropertyProjection.ts src/domain/graph/publicGraphSubstrate.ts test/unit/domain/graph/LegacyPropertyProjection.test.ts + +npm run typecheck +npm run lint:sludge +``` + ## Closeout Criteria - Property compatibility has named runtime forms. @@ -116,10 +137,11 @@ git diff --check HEAD ## SSJS Scorecard -- Runtime-backed forms: green when each property concept is a frozen class. -- Boundary validation: green when raw keys are decoded once and invalid keys - fail closed. -- Behavior ownership: green when key classification lives with the projection. +- Runtime-backed forms: green; each property concept is a frozen class. +- Boundary validation: green; raw key values are validated by property key + nouns. +- Behavior ownership: green; key classification lives with the key noun + concept. - Message parsing: green; no behavior branches on diagnostics. - Ambient time or entropy: green; no clocks or randomness. - Fake shape trust or cast-cosplay: green when no assertions or placeholder diff --git a/docs/design/0176-v18-node-property-projection/v18-node-property-projection.md b/docs/design/0176-v18-node-property-projection/v18-node-property-projection.md index 582c2768..5fb8a048 100644 --- a/docs/design/0176-v18-node-property-projection/v18-node-property-projection.md +++ b/docs/design/0176-v18-node-property-projection/v18-node-property-projection.md @@ -1,11 +1,12 @@ --- cycle: 0176 task_id: V18_node_property_projection -status: Planned +status: Complete sponsors: human: James agent: Codex started_at: 2026-05-23 +completed_at: 2026-05-23 release_home: v18.0.0 bearing_task: 28 promotes_backlog: @@ -77,7 +78,7 @@ lower records back to the existing public object shape. ## RED Plan -Add tests that currently fail without the projection: +Added tests that failed without the projection: - a live node with two properties projects two immutable records; - a removed node's property register does not appear; @@ -85,25 +86,44 @@ Add tests that currently fail without the projection: the existing visible-state policy; - content compatibility keys are classified consistently. +Observed RED: + +```text +npx vitest run test/unit/domain/services/NodePropertyProjection.test.ts --reporter=verbose +Error: Cannot find module '../../../../src/domain/services/NodePropertyProjection.ts' +``` + ## GREEN Plan -Implement a small projection object or service with explicit methods such as -`projectVisibleNodeProperties(state)`. Keep supporting helpers private and -concept-named. Avoid a `utils` file. +Implemented `NodePropertyProjection` with explicit `fromState()` and +`forNode()` methods. Supporting functions stay private and concept-named. -The implementation should reuse existing node-record projection rather than -recomputing node liveness in a second ad hoc way. +The implementation reuses `WarpState.getNodeRecord()` for node liveness +instead of recomputing liveness in a second ad hoc way. ## Verification ```text -npx vitest run test/unit/domain/graph/NodePropertyProjection.test.ts --reporter=verbose -npx eslint src/domain/graph test/unit/domain/graph/NodePropertyProjection.test.ts +npx vitest run test/unit/domain/services/NodePropertyProjection.test.ts --reporter=verbose +npx eslint src/domain/services/NodePropertyProjection.ts test/unit/domain/services/NodePropertyProjection.test.ts npm run typecheck npm run lint:sludge git diff --check HEAD ``` +Observed GREEN: + +```text +npx vitest run test/unit/domain/services/NodePropertyProjection.test.ts --reporter=verbose +Test Files 1 passed (1) +Tests 4 passed (4) + +npx eslint src/domain/services/NodePropertyProjection.ts test/unit/domain/services/NodePropertyProjection.test.ts + +npm run typecheck +npm run lint:sludge +``` + ## Closeout Criteria - Visible node property projection exists as a named domain service. @@ -113,12 +133,11 @@ git diff --check HEAD ## SSJS Scorecard -- Runtime-backed forms: green when visible node properties are records, not - raw object fragments. -- Boundary validation: green when legacy key decoding happens at projection - entry. -- Behavior ownership: green when node visibility and property classification - belong to the projection. +- Runtime-backed forms: green; visible node properties are records, not raw + object fragments. +- Boundary validation: green; legacy key decoding happens at projection entry. +- Behavior ownership: green; node visibility belongs to `WarpState` and + property classification belongs to the property key noun. - Message parsing: green; no message-string branching. - Ambient time or entropy: green; no clock or random source. - Fake shape trust or cast-cosplay: green when no assertions are needed. diff --git a/docs/design/0177-v18-edge-property-projection/v18-edge-property-projection.md b/docs/design/0177-v18-edge-property-projection/v18-edge-property-projection.md index 831bbc7e..9be4cf74 100644 --- a/docs/design/0177-v18-edge-property-projection/v18-edge-property-projection.md +++ b/docs/design/0177-v18-edge-property-projection/v18-edge-property-projection.md @@ -1,11 +1,12 @@ --- cycle: 0177 task_id: V18_edge_property_projection -status: Planned +status: Complete sponsors: human: James agent: Codex started_at: 2026-05-23 +completed_at: 2026-05-23 release_home: v18.0.0 bearing_task: 29 promotes_backlog: @@ -75,31 +76,52 @@ small edge-record API improvement before this slice proceeds. ## RED Plan -Add tests that fail until edge projection exists: +Added tests that failed until edge projection existed: - a visible edge with properties projects immutable edge property records; - an edge removed after property assignment does not project properties; - a property keyed to malformed edge coordinates fails closed; - content compatibility keys are classified without corrupting public values. +Observed RED: + +```text +npx vitest run test/unit/domain/services/EdgePropertyProjection.test.ts --reporter=verbose +Error: Cannot find module '../../../../src/domain/services/EdgePropertyProjection.ts' +``` + ## GREEN Plan -Implement the projection in a concept-named graph-substrate file. Reuse -`EdgeRecord` as the visibility gate. Keep iteration deterministic by sorting -on edge identity and property key where no stronger order already exists. +Implemented the projection in a concept-named domain service. It reuses +`WarpState.getEdgeRecord()` as the visibility gate and filters registers that +predate the current edge birth. Iteration is deterministic by edge identity +and property key. Tests should compare record values, not JSON string output. ## Verification ```text -npx vitest run test/unit/domain/graph/EdgePropertyProjection.test.ts --reporter=verbose -npx eslint src/domain/graph test/unit/domain/graph/EdgePropertyProjection.test.ts +npx vitest run test/unit/domain/services/EdgePropertyProjection.test.ts --reporter=verbose +npx eslint src/domain/services/EdgePropertyProjection.ts test/unit/domain/services/EdgePropertyProjection.test.ts npm run typecheck npm run lint:sludge git diff --check HEAD ``` +Observed GREEN: + +```text +npx vitest run test/unit/domain/services/EdgePropertyProjection.test.ts --reporter=verbose +Test Files 1 passed (1) +Tests 4 passed (4) + +npx eslint src/domain/services/EdgePropertyProjection.ts test/unit/domain/services/EdgePropertyProjection.test.ts + +npm run typecheck +npm run lint:sludge +``` + ## Closeout Criteria - Visible edge property projection exists. @@ -109,10 +131,11 @@ git diff --check HEAD ## SSJS Scorecard -- Runtime-backed forms: green when edge properties are frozen records. -- Boundary validation: green when malformed edge keys fail at projection - construction. -- Behavior ownership: green when edge visibility is delegated to edge records. +- Runtime-backed forms: green; edge properties are frozen records. +- Boundary validation: green; malformed edge keys fail closed at projection + entry. +- Behavior ownership: green; edge visibility is delegated to `WarpState` + edge records. - Message parsing: green; no parsed prose controls logic. - Ambient time or entropy: green; no ambient sources. - Fake shape trust or cast-cosplay: green when casts are not introduced. diff --git a/docs/design/0178-v18-query-property-projection-reads/v18-query-property-projection-reads.md b/docs/design/0178-v18-query-property-projection-reads/v18-query-property-projection-reads.md index 7bccae25..6a6ccead 100644 --- a/docs/design/0178-v18-query-property-projection-reads/v18-query-property-projection-reads.md +++ b/docs/design/0178-v18-query-property-projection-reads/v18-query-property-projection-reads.md @@ -1,11 +1,12 @@ --- cycle: 0178 task_id: V18_query_property_projection_reads -status: Planned +status: Complete sponsors: human: James agent: Codex started_at: 2026-05-23 +completed_at: 2026-05-23 release_home: v18.0.0 bearing_task: 30 promotes_backlog: @@ -73,24 +74,30 @@ match the public contract rather than changing assertions casually. ## RED Plan -Add characterization tests around the current public query behavior before -rewiring: +Added characterization tests around the public query behavior before rewiring: - node property reads return the same object shape after projection routing; - edge property reads return the same object shape after projection routing; - `getEdges()` includes the same property payloads for representative edges; - reserved content compatibility keys behave exactly as before. -At least one test should assert an implementation seam: a malformed raw edge +At least one test asserted an implementation seam: a malformed raw edge property key must not be able to bypass the projection path. +Observed RED: + +```text +npx vitest run test/unit/domain/services/QueryReadsPropertyProjection.test.ts --reporter=verbose +AssertionError: expected { status: 'ready', ... } to deeply equal { _content: 'abc123', status: 'ready' } +``` + ## GREEN Plan -Inject or construct property projections at the query-read boundary. Then -replace direct calls to `decodePropKey` and `decodeEdgePropKey` inside query -formatting with projection records. +Constructed property projections at the query-read boundary. Direct calls to +`decodePropKey` and `decodeEdgePropKey` were removed from query property +formatting and replaced with projection records. -Keep conversion from projection records to public objects in small, +Conversion from projection records to public objects stays in small, concept-named functions near the query controller. ## Verification @@ -104,6 +111,22 @@ npm run lint:sludge git diff --check HEAD ``` +Observed GREEN: + +```text +npx vitest run test/unit/domain/services/QueryReadsPropertyProjection.test.ts test/unit/domain/services/NodePropertyProjection.test.ts test/unit/domain/services/EdgePropertyProjection.test.ts --reporter=verbose +Test Files 3 passed (3) +Tests 10 passed (10) + +npx vitest run test/unit/domain/WarpGraph.query.test.ts test/unit/domain/WarpGraph.edgeProps.test.ts test/unit/domain/WarpGraph.edgePropVisibility.test.ts test/unit/domain/services/QueryReadsPropertyProjection.test.ts --reporter=verbose +Test Files 4 passed (4) +Tests 48 passed (48) + +npm run typecheck +npm run lint +npm run lint:sludge +``` + ## Closeout Criteria - Public query property reads are projection-backed. @@ -114,9 +137,9 @@ git diff --check HEAD ## SSJS Scorecard -- Runtime-backed forms: green when query formatting consumes property records. -- Boundary validation: green when raw keys are decoded only inside projection. -- Behavior ownership: green when the query controller formats, not decodes. +- Runtime-backed forms: green; query formatting consumes property records. +- Boundary validation: green; raw keys are decoded only inside projection. +- Behavior ownership: green; the query controller formats, not decodes. - Message parsing: green; no prose-driven logic. - Ambient time or entropy: green; no domain clocks or randomness. - Fake shape trust or cast-cosplay: green when no assertions are added. diff --git a/src/domain/graph/LegacyContentPropertyKeys.ts b/src/domain/graph/LegacyContentPropertyKeys.ts new file mode 100644 index 00000000..707555dc --- /dev/null +++ b/src/domain/graph/LegacyContentPropertyKeys.ts @@ -0,0 +1,17 @@ +/** + * Well-known legacy property key for content attachment. + * Stores a content-addressed blob OID as the property value. + */ +export const CONTENT_PROPERTY_KEY = '_content'; + +/** + * Well-known legacy property key for attached content MIME metadata. + * Stores a MIME type hint for the attached logical content referenced by `_content`. + */ +export const CONTENT_MIME_PROPERTY_KEY = '_content.mime'; + +/** + * Well-known legacy property key for attached content byte-size metadata. + * Stores the byte length of the attached logical content referenced by `_content`. + */ +export const CONTENT_SIZE_PROPERTY_KEY = '_content.size'; diff --git a/src/domain/graph/LegacyEdgePropertyKey.ts b/src/domain/graph/LegacyEdgePropertyKey.ts new file mode 100644 index 00000000..a665b736 --- /dev/null +++ b/src/domain/graph/LegacyEdgePropertyKey.ts @@ -0,0 +1,39 @@ +import { + classifyLegacyPropertyKey, + isContentCompatibilityClassification, + requireLegacyPropertyKeyValue, + type LegacyPropertyKeyClassification, +} from './LegacyPropertyKeyClassification.ts'; + +/** Runtime-backed key for a legacy edge property compatibility slot. */ +export default class LegacyEdgePropertyKey { + private readonly value: string; + + constructor(value: string) { + this.value = requireLegacyPropertyKeyValue(value, 'LegacyEdgePropertyKey'); + Object.freeze(this); + } + + /** Returns the stable legacy property key string. */ + toString(): string { + return this.value; + } + + /** Classifies this key for compatibility projection decisions. */ + classification(): LegacyPropertyKeyClassification { + return classifyLegacyPropertyKey(this.value); + } + + /** Returns true when this key belongs to legacy content compatibility. */ + isContentCompatibilityKey(): boolean { + return isContentCompatibilityClassification(this.classification()); + } + + /** Compares edge property keys by runtime value. */ + equals(other: LegacyEdgePropertyKey | null | undefined): boolean { + if (!(other instanceof LegacyEdgePropertyKey)) { + return false; + } + return this.value === other.value; + } +} diff --git a/src/domain/graph/LegacyNodePropertyKey.ts b/src/domain/graph/LegacyNodePropertyKey.ts new file mode 100644 index 00000000..6e4d63a1 --- /dev/null +++ b/src/domain/graph/LegacyNodePropertyKey.ts @@ -0,0 +1,39 @@ +import { + classifyLegacyPropertyKey, + isContentCompatibilityClassification, + requireLegacyPropertyKeyValue, + type LegacyPropertyKeyClassification, +} from './LegacyPropertyKeyClassification.ts'; + +/** Runtime-backed key for a legacy node property compatibility slot. */ +export default class LegacyNodePropertyKey { + private readonly value: string; + + constructor(value: string) { + this.value = requireLegacyPropertyKeyValue(value, 'LegacyNodePropertyKey'); + Object.freeze(this); + } + + /** Returns the stable legacy property key string. */ + toString(): string { + return this.value; + } + + /** Classifies this key for compatibility projection decisions. */ + classification(): LegacyPropertyKeyClassification { + return classifyLegacyPropertyKey(this.value); + } + + /** Returns true when this key belongs to legacy content compatibility. */ + isContentCompatibilityKey(): boolean { + return isContentCompatibilityClassification(this.classification()); + } + + /** Compares node property keys by runtime value. */ + equals(other: LegacyNodePropertyKey | null | undefined): boolean { + if (!(other instanceof LegacyNodePropertyKey)) { + return false; + } + return this.value === other.value; + } +} diff --git a/src/domain/graph/LegacyPropertyKeyClassification.ts b/src/domain/graph/LegacyPropertyKeyClassification.ts new file mode 100644 index 00000000..0b7d3dd2 --- /dev/null +++ b/src/domain/graph/LegacyPropertyKeyClassification.ts @@ -0,0 +1,53 @@ +import WarpError from '../errors/WarpError.ts'; +import { + CONTENT_MIME_PROPERTY_KEY, + CONTENT_PROPERTY_KEY, + CONTENT_SIZE_PROPERTY_KEY, +} from './LegacyContentPropertyKeys.ts'; + +const FIELD_SEPARATOR = '\x00'; + +export const LEGACY_PROPERTY_KEY_USER = 'user'; +export const LEGACY_PROPERTY_KEY_CONTENT_OID = 'content-oid'; +export const LEGACY_PROPERTY_KEY_CONTENT_MIME = 'content-mime'; +export const LEGACY_PROPERTY_KEY_CONTENT_SIZE = 'content-size'; + +export type LegacyPropertyKeyClassification = + | typeof LEGACY_PROPERTY_KEY_USER + | typeof LEGACY_PROPERTY_KEY_CONTENT_OID + | typeof LEGACY_PROPERTY_KEY_CONTENT_MIME + | typeof LEGACY_PROPERTY_KEY_CONTENT_SIZE; + +/** Classifies a legacy compatibility property key. */ +export function classifyLegacyPropertyKey(value: string): LegacyPropertyKeyClassification { + if (value === CONTENT_PROPERTY_KEY) { + return LEGACY_PROPERTY_KEY_CONTENT_OID; + } + if (value === CONTENT_MIME_PROPERTY_KEY) { + return LEGACY_PROPERTY_KEY_CONTENT_MIME; + } + if (value === CONTENT_SIZE_PROPERTY_KEY) { + return LEGACY_PROPERTY_KEY_CONTENT_SIZE; + } + return LEGACY_PROPERTY_KEY_USER; +} + +/** Returns true when a classification belongs to legacy content metadata. */ +export function isContentCompatibilityClassification( + classification: LegacyPropertyKeyClassification, +): boolean { + return classification === LEGACY_PROPERTY_KEY_CONTENT_OID + || classification === LEGACY_PROPERTY_KEY_CONTENT_MIME + || classification === LEGACY_PROPERTY_KEY_CONTENT_SIZE; +} + +/** Validates the shared legacy property key carrier. */ +export function requireLegacyPropertyKeyValue(value: string, nounName: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new WarpError(`${nounName} must be a non-empty string`, 'E_VALIDATION'); + } + if (value.includes(FIELD_SEPARATOR)) { + throw new WarpError(`${nounName} must not contain NUL bytes`, 'E_VALIDATION'); + } + return value; +} diff --git a/src/domain/graph/LegacyPropertyProjection.ts b/src/domain/graph/LegacyPropertyProjection.ts new file mode 100644 index 00000000..841ac162 --- /dev/null +++ b/src/domain/graph/LegacyPropertyProjection.ts @@ -0,0 +1,109 @@ +import EdgeRecord from './EdgeRecord.ts'; +import NodeRecord from './NodeRecord.ts'; +import VisibleEdgePropertyRecord from './VisibleEdgePropertyRecord.ts'; +import VisibleNodePropertyRecord from './VisibleNodePropertyRecord.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type LegacyPropertyProjectionFields = { + readonly nodeProperties: readonly VisibleNodePropertyRecord[]; + readonly edgeProperties: readonly VisibleEdgePropertyRecord[]; +}; + +/** Runtime-backed collection of visible legacy property compatibility records. */ +export default class LegacyPropertyProjection { + readonly nodeProperties: readonly VisibleNodePropertyRecord[]; + readonly edgeProperties: readonly VisibleEdgePropertyRecord[]; + + constructor(fields: LegacyPropertyProjectionFields) { + const checkedFields = requireFields(fields); + this.nodeProperties = requireNodeProperties(checkedFields.nodeProperties); + this.edgeProperties = requireEdgeProperties(checkedFields.edgeProperties); + Object.freeze(this); + } + + /** Returns visible property records for a node owner. */ + propertiesForNode(owner: NodeRecord): readonly VisibleNodePropertyRecord[] { + const checkedOwner = requireNodeOwner(owner); + return Object.freeze(this.nodeProperties.filter((record) => record.owner.equals(checkedOwner))); + } + + /** Returns visible property records for an edge owner. */ + propertiesForEdge(owner: EdgeRecord): readonly VisibleEdgePropertyRecord[] { + const checkedOwner = requireEdgeOwner(owner); + return Object.freeze(this.edgeProperties.filter((record) => record.owner.equals(checkedOwner))); + } +} + +/** Validates the projection constructor envelope. */ +function requireFields( + fields: LegacyPropertyProjectionFields | null | undefined, +): LegacyPropertyProjectionFields { + if (fields === null || fields === undefined) { + throw new WarpError('LegacyPropertyProjection fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +/** Requires a node owner for projection lookup. */ +function requireNodeOwner(owner: NodeRecord): NodeRecord { + if (!(owner instanceof NodeRecord)) { + throw new WarpError('LegacyPropertyProjection node owner must be a NodeRecord', 'E_VALIDATION'); + } + return owner; +} + +/** Requires an edge owner for projection lookup. */ +function requireEdgeOwner(owner: EdgeRecord): EdgeRecord { + if (!(owner instanceof EdgeRecord)) { + throw new WarpError('LegacyPropertyProjection edge owner must be an EdgeRecord', 'E_VALIDATION'); + } + return owner; +} + +/** Requires immutable node property records. */ +function requireNodeProperties( + records: readonly VisibleNodePropertyRecord[], +): readonly VisibleNodePropertyRecord[] { + if (!Array.isArray(records)) { + throw new WarpError( + 'LegacyPropertyProjection nodeProperties must be an array', + 'E_VALIDATION', + ); + } + return Object.freeze(records.map((record) => requireNodeProperty(record))); +} + +/** Requires immutable edge property records. */ +function requireEdgeProperties( + records: readonly VisibleEdgePropertyRecord[], +): readonly VisibleEdgePropertyRecord[] { + if (!Array.isArray(records)) { + throw new WarpError( + 'LegacyPropertyProjection edgeProperties must be an array', + 'E_VALIDATION', + ); + } + return Object.freeze(records.map((record) => requireEdgeProperty(record))); +} + +/** Requires a runtime-backed visible node property record. */ +function requireNodeProperty(record: VisibleNodePropertyRecord): VisibleNodePropertyRecord { + if (!(record instanceof VisibleNodePropertyRecord)) { + throw new WarpError( + 'LegacyPropertyProjection nodeProperties entries must be VisibleNodePropertyRecord values', + 'E_VALIDATION', + ); + } + return record; +} + +/** Requires a runtime-backed visible edge property record. */ +function requireEdgeProperty(record: VisibleEdgePropertyRecord): VisibleEdgePropertyRecord { + if (!(record instanceof VisibleEdgePropertyRecord)) { + throw new WarpError( + 'LegacyPropertyProjection edgeProperties entries must be VisibleEdgePropertyRecord values', + 'E_VALIDATION', + ); + } + return record; +} diff --git a/src/domain/graph/LegacyPropertyValue.ts b/src/domain/graph/LegacyPropertyValue.ts new file mode 100644 index 00000000..09a84b19 --- /dev/null +++ b/src/domain/graph/LegacyPropertyValue.ts @@ -0,0 +1,51 @@ +import WarpError from '../errors/WarpError.ts'; +import { isPropValue, type PropValue } from '../types/PropValue.ts'; + +/** Runtime-backed value for a legacy property compatibility record. */ +export default class LegacyPropertyValue { + private readonly value: PropValue; + + constructor(value: PropValue) { + this.value = clonePropValue(requirePropValue(value)); + Object.freeze(this); + } + + /** Returns a defensive copy of the property-compatible value. */ + toPropValue(): PropValue { + return clonePropValue(this.value); + } +} + +/** Requires a value that can live in the property register. */ +function requirePropValue(value: PropValue): PropValue { + if (!isPropValue(value)) { + throw new WarpError('LegacyPropertyValue must wrap a PropValue', 'E_VALIDATION'); + } + return value; +} + +/** Copies recursive property values so source carriers stay outside the noun. */ +function clonePropValue(value: PropValue): PropValue { + if (value instanceof Uint8Array) { + return new Uint8Array(value); + } + if (Array.isArray(value)) { + return value.map((entry) => clonePropValue(entry)); + } + if (isPropValueObject(value)) { + const copy: { [key: string]: PropValue } = {}; + for (const [key, entry] of Object.entries(value)) { + copy[key] = clonePropValue(entry); + } + return copy; + } + return value; +} + +/** Narrows recursive property objects. */ +function isPropValueObject(value: PropValue): value is { [key: string]: PropValue } { + return value !== null + && typeof value === 'object' + && !(value instanceof Uint8Array) + && !Array.isArray(value); +} diff --git a/src/domain/graph/VisibleEdgePropertyRecord.ts b/src/domain/graph/VisibleEdgePropertyRecord.ts new file mode 100644 index 00000000..5de9137f --- /dev/null +++ b/src/domain/graph/VisibleEdgePropertyRecord.ts @@ -0,0 +1,65 @@ +import EdgeRecord from './EdgeRecord.ts'; +import LegacyEdgePropertyKey from './LegacyEdgePropertyKey.ts'; +import LegacyPropertyValue from './LegacyPropertyValue.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type VisibleEdgePropertyRecordFields = { + readonly owner: EdgeRecord; + readonly key: LegacyEdgePropertyKey; + readonly value: LegacyPropertyValue; +}; + +/** Runtime-backed visible legacy property record owned by an edge. */ +export default class VisibleEdgePropertyRecord { + readonly owner: EdgeRecord; + readonly key: LegacyEdgePropertyKey; + readonly value: LegacyPropertyValue; + + constructor(fields: VisibleEdgePropertyRecordFields) { + const checkedFields = requireFields(fields); + this.owner = requireOwner(checkedFields.owner); + this.key = requireKey(checkedFields.key); + this.value = requireValue(checkedFields.value); + Object.freeze(this); + } +} + +/** Validates the edge property record constructor envelope. */ +function requireFields( + fields: VisibleEdgePropertyRecordFields | null | undefined, +): VisibleEdgePropertyRecordFields { + if (fields === null || fields === undefined) { + throw new WarpError('VisibleEdgePropertyRecord fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +/** Requires a runtime-backed edge owner. */ +function requireOwner(owner: EdgeRecord): EdgeRecord { + if (!(owner instanceof EdgeRecord)) { + throw new WarpError('VisibleEdgePropertyRecord owner must be an EdgeRecord', 'E_VALIDATION'); + } + return owner; +} + +/** Requires a runtime-backed edge property key. */ +function requireKey(key: LegacyEdgePropertyKey): LegacyEdgePropertyKey { + if (!(key instanceof LegacyEdgePropertyKey)) { + throw new WarpError( + 'VisibleEdgePropertyRecord key must be a LegacyEdgePropertyKey', + 'E_VALIDATION', + ); + } + return key; +} + +/** Requires a runtime-backed property value. */ +function requireValue(value: LegacyPropertyValue): LegacyPropertyValue { + if (!(value instanceof LegacyPropertyValue)) { + throw new WarpError( + 'VisibleEdgePropertyRecord value must be a LegacyPropertyValue', + 'E_VALIDATION', + ); + } + return value; +} diff --git a/src/domain/graph/VisibleNodePropertyRecord.ts b/src/domain/graph/VisibleNodePropertyRecord.ts new file mode 100644 index 00000000..857569d8 --- /dev/null +++ b/src/domain/graph/VisibleNodePropertyRecord.ts @@ -0,0 +1,65 @@ +import LegacyNodePropertyKey from './LegacyNodePropertyKey.ts'; +import LegacyPropertyValue from './LegacyPropertyValue.ts'; +import NodeRecord from './NodeRecord.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type VisibleNodePropertyRecordFields = { + readonly owner: NodeRecord; + readonly key: LegacyNodePropertyKey; + readonly value: LegacyPropertyValue; +}; + +/** Runtime-backed visible legacy property record owned by a node. */ +export default class VisibleNodePropertyRecord { + readonly owner: NodeRecord; + readonly key: LegacyNodePropertyKey; + readonly value: LegacyPropertyValue; + + constructor(fields: VisibleNodePropertyRecordFields) { + const checkedFields = requireFields(fields); + this.owner = requireOwner(checkedFields.owner); + this.key = requireKey(checkedFields.key); + this.value = requireValue(checkedFields.value); + Object.freeze(this); + } +} + +/** Validates the node property record constructor envelope. */ +function requireFields( + fields: VisibleNodePropertyRecordFields | null | undefined, +): VisibleNodePropertyRecordFields { + if (fields === null || fields === undefined) { + throw new WarpError('VisibleNodePropertyRecord fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +/** Requires a runtime-backed node owner. */ +function requireOwner(owner: NodeRecord): NodeRecord { + if (!(owner instanceof NodeRecord)) { + throw new WarpError('VisibleNodePropertyRecord owner must be a NodeRecord', 'E_VALIDATION'); + } + return owner; +} + +/** Requires a runtime-backed node property key. */ +function requireKey(key: LegacyNodePropertyKey): LegacyNodePropertyKey { + if (!(key instanceof LegacyNodePropertyKey)) { + throw new WarpError( + 'VisibleNodePropertyRecord key must be a LegacyNodePropertyKey', + 'E_VALIDATION', + ); + } + return key; +} + +/** Requires a runtime-backed property value. */ +function requireValue(value: LegacyPropertyValue): LegacyPropertyValue { + if (!(value instanceof LegacyPropertyValue)) { + throw new WarpError( + 'VisibleNodePropertyRecord value must be a LegacyPropertyValue', + 'E_VALIDATION', + ); + } + return value; +} diff --git a/src/domain/graph/publicGraphSubstrate.ts b/src/domain/graph/publicGraphSubstrate.ts index e8450a65..189d2579 100644 --- a/src/domain/graph/publicGraphSubstrate.ts +++ b/src/domain/graph/publicGraphSubstrate.ts @@ -14,9 +14,15 @@ export { default as GraphAttachmentSetOp } from './GraphAttachmentSetOp.ts'; export { default as GraphEdgeRecordSetOp } from './GraphEdgeRecordSetOp.ts'; export { default as GraphNodeRecordSetOp } from './GraphNodeRecordSetOp.ts'; export { default as GraphOpAlgebra } from './GraphOpAlgebra.ts'; +export { default as LegacyEdgePropertyKey } from './LegacyEdgePropertyKey.ts'; +export { default as LegacyNodePropertyKey } from './LegacyNodePropertyKey.ts'; +export { default as LegacyPropertyProjection } from './LegacyPropertyProjection.ts'; +export { default as LegacyPropertyValue } from './LegacyPropertyValue.ts'; export { default as NodeId } from './NodeId.ts'; export { default as NodeRecord } from './NodeRecord.ts'; export { default as NodeTypeId } from './NodeTypeId.ts'; +export { default as VisibleEdgePropertyRecord } from './VisibleEdgePropertyRecord.ts'; +export { default as VisibleNodePropertyRecord } from './VisibleNodePropertyRecord.ts'; export { CURRENT_ATTACHMENT_SCHEMA_VERSION, @@ -30,6 +36,12 @@ export { export { GRAPH_NODE_RECORD_SET_OP, } from './GraphNodeRecordSetOp.ts'; +export { + LEGACY_PROPERTY_KEY_CONTENT_MIME, + LEGACY_PROPERTY_KEY_CONTENT_OID, + LEGACY_PROPERTY_KEY_CONTENT_SIZE, + LEGACY_PROPERTY_KEY_USER, +} from './LegacyPropertyKeyClassification.ts'; export { DEFAULT_NODE_TYPE_ID, } from './NodeTypeId.ts'; @@ -47,4 +59,8 @@ export type { GraphEdgeRecordSetOpFields } from './GraphEdgeRecordSetOp.ts'; export type { GraphNodeRecordSetOpFields } from './GraphNodeRecordSetOp.ts'; export type { GraphOpAlgebraFields } from './GraphOpAlgebra.ts'; export type { GraphOperation } from './GraphOperation.ts'; +export type { LegacyPropertyKeyClassification } from './LegacyPropertyKeyClassification.ts'; +export type { LegacyPropertyProjectionFields } from './LegacyPropertyProjection.ts'; export type { NodeRecordFields } from './NodeRecord.ts'; +export type { VisibleEdgePropertyRecordFields } from './VisibleEdgePropertyRecord.ts'; +export type { VisibleNodePropertyRecordFields } from './VisibleNodePropertyRecord.ts'; diff --git a/src/domain/services/EdgePropertyProjection.ts b/src/domain/services/EdgePropertyProjection.ts new file mode 100644 index 00000000..573c7079 --- /dev/null +++ b/src/domain/services/EdgePropertyProjection.ts @@ -0,0 +1,305 @@ +import EdgeRecord from '../graph/EdgeRecord.ts'; +import LegacyEdgePropertyKey from '../graph/LegacyEdgePropertyKey.ts'; +import LegacyPropertyValue from '../graph/LegacyPropertyValue.ts'; +import VisibleEdgePropertyRecord from '../graph/VisibleEdgePropertyRecord.ts'; +import WarpError from '../errors/WarpError.ts'; +import { + EDGE_PROP_PREFIX, + FIELD_SEPARATOR, + encodeEdgeKey, + encodeEdgePropKey, +} from './KeyCodec.ts'; +import { + isLegacyEdgePropertyProjectionTarget, + type LegacyEdgePropertyProjectionTarget, +} from './LegacyPropertyProjectionTarget.ts'; +import WarpState from './state/WarpState.ts'; +import { compareEventIds, type EventId } from '../utils/EventId.ts'; +import { compareStrings } from '../utils/StringComparison.ts'; +import type { LWWRegister } from '../crdt/LWW.ts'; +import type { PropValue } from '../types/PropValue.ts'; + +export type EdgePropertyProjectionEdge = LegacyEdgePropertyProjectionTarget; + +type EdgePropertyKeyParts = EdgePropertyProjectionEdge & { + readonly propKey: string; +}; + +type EdgePropertyKeySegmentValues = { + readonly from: string | undefined; + readonly to: string | undefined; + readonly label: string | undefined; + readonly propKey: string | undefined; +}; + +type CompleteEdgePropertyKeySegmentValues = { + readonly from: string; + readonly to: string; + readonly label: string; + readonly propKey: string; +}; + +type EdgeOwnerRegisterProjection = { + readonly state: WarpState; + readonly owner: EdgeRecord; + readonly encodedKey: string; + readonly register: LWWRegister; +}; + +/** Projects visible legacy edge properties into compatibility records. */ +export default class EdgePropertyProjection { + /** Returns all visible edge property records in deterministic order. */ + static fromState(state: WarpState): readonly VisibleEdgePropertyRecord[] { + const checkedState = requireWarpState(state); + const records: VisibleEdgePropertyRecord[] = []; + for (const [encodedKey, register] of checkedState.prop) { + const record = edgePropertyRecordForRegister(checkedState, encodedKey, register); + if (record !== null) { + records.push(record); + } + } + records.sort(compareEdgePropertyRecords); + return Object.freeze(records); + } + + /** Returns visible property records for one edge in deterministic order. */ + static forEdge( + state: WarpState, + edge: EdgePropertyProjectionEdge, + ): readonly VisibleEdgePropertyRecord[] { + const checkedState = requireWarpState(state); + const owner = edgeRecordForProjectionTarget(checkedState, edge); + if (owner === null) { + return Object.freeze([]); + } + return EdgePropertyProjection.forEdgeRecord(checkedState, owner); + } + + /** Returns visible property records for one runtime-backed edge owner. */ + static forEdgeRecord( + state: WarpState, + owner: EdgeRecord, + ): readonly VisibleEdgePropertyRecord[] { + const checkedState = requireWarpState(state); + const checkedOwner = requireEdgeRecord(owner); + const records: VisibleEdgePropertyRecord[] = []; + const ownerKeyPrefix = edgePropertyKeyPrefix(checkedOwner); + for (const [encodedKey, register] of checkedState.prop) { + if (!encodedKey.startsWith(ownerKeyPrefix)) { + continue; + } + const record = edgePropertyRecordForOwnerRegister({ + state: checkedState, + owner: checkedOwner, + encodedKey, + register, + }); + if (record !== null) { + records.push(record); + } + } + records.sort(compareEdgePropertyRecords); + return Object.freeze(records); + } +} + +/** Requires a runtime-backed WarpState source. */ +function requireWarpState(state: WarpState): WarpState { + if (!(state instanceof WarpState)) { + throw new WarpError('EdgePropertyProjection source must be a WarpState', 'E_VALIDATION'); + } + return state; +} + +/** Requires a runtime-backed edge record owner. */ +function requireEdgeRecord(owner: EdgeRecord): EdgeRecord { + if (!(owner instanceof EdgeRecord)) { + throw new WarpError('EdgePropertyProjection owner must be an EdgeRecord', 'E_VALIDATION'); + } + return owner; +} + +/** Resolves a projection target without throwing on public miss carriers. */ +function edgeRecordForProjectionTarget( + state: WarpState, + edge: EdgePropertyProjectionEdge, +): EdgeRecord | null { + const owner = edgeRecordFromProjectionTarget(edge); + if (owner === null) { + return null; + } + return state.getEdgeRecord(owner.id); +} + +/** Builds an edge property record from one legacy state register. */ +function edgePropertyRecordForRegister( + state: WarpState, + encodedKey: string, + register: LWWRegister, +): VisibleEdgePropertyRecord | null { + const keyParts = decodeVisibleEdgePropertyKey(encodedKey); + if (keyParts === null) { + return null; + } + const edgeKey = encodeEdgeKey(keyParts.from, keyParts.to, keyParts.label); + const visibleRegister = visibleEdgeRegister(register, state.edgeBirthEvent.get(edgeKey)); + if (visibleRegister === null) { + return null; + } + const decodedOwner = edgeRecordFromProjectionTarget(keyParts); + if (decodedOwner === null) { + return null; + } + const owner = state.getEdgeRecord(decodedOwner.id); + if (owner === null) { + return null; + } + return new VisibleEdgePropertyRecord({ + owner, + key: new LegacyEdgePropertyKey(keyParts.propKey), + value: new LegacyPropertyValue(visibleRegister.value), + }); +} + +/** Builds an edge property record when it belongs to the requested owner. */ +function edgePropertyRecordForOwnerRegister( + projection: EdgeOwnerRegisterProjection, +): VisibleEdgePropertyRecord | null { + const keyParts = decodeVisibleEdgePropertyKey(projection.encodedKey); + if (keyParts === null || !edgePropertyKeyPartsMatchOwner(keyParts, projection.owner)) { + return null; + } + const edgeKey = encodeEdgeKey(keyParts.from, keyParts.to, keyParts.label); + const visibleRegister = visibleEdgeRegister( + projection.register, + projection.state.edgeBirthEvent.get(edgeKey), + ); + if (visibleRegister === null) { + return null; + } + return new VisibleEdgePropertyRecord({ + owner: projection.owner, + key: new LegacyEdgePropertyKey(keyParts.propKey), + value: new LegacyPropertyValue(visibleRegister.value), + }); +} + +/** Returns true when decoded edge-property key parts belong to an owner record. */ +function edgePropertyKeyPartsMatchOwner( + keyParts: EdgePropertyKeyParts, + owner: EdgeRecord, +): boolean { + return keyParts.from === owner.from.toString() + && keyParts.to === owner.to.toString() + && keyParts.label === owner.typeId.toString(); +} + +/** Builds a runtime edge record only for validated legacy projection targets. */ +function edgeRecordFromProjectionTarget(edge: EdgePropertyProjectionEdge): EdgeRecord | null { + if (!isLegacyEdgePropertyProjectionTarget(edge)) { + return null; + } + return EdgeRecord.fromLegacyEdge(edge); +} + +/** Returns the exact encoded edge-property prefix for one edge owner. */ +function edgePropertyKeyPrefix(owner: EdgeRecord): string { + return encodeEdgePropKey( + owner.from.toString(), + owner.to.toString(), + owner.typeId.toString(), + '', + ); +} + +/** Decodes only well-formed legacy edge property keys. */ +function decodeVisibleEdgePropertyKey(encodedKey: string): EdgePropertyKeyParts | null { + if (!encodedKey.startsWith(EDGE_PROP_PREFIX)) { + return null; + } + const parts = encodedKey.slice(1).split(FIELD_SEPARATOR); + return edgePropertyKeyPartsFromSegments(parts); +} + +/** Returns edge property parts only when the legacy segments are well-formed. */ +function edgePropertyKeyPartsFromSegments(parts: readonly string[]): EdgePropertyKeyParts | null { + if (parts.length !== 4) { + return null; + } + return edgePropertyKeyPartsFromValues({ + from: parts[0], + to: parts[1], + label: parts[2], + propKey: parts[3], + }); +} + +/** Returns edge property parts when all decoded values are non-empty. */ +function edgePropertyKeyPartsFromValues( + values: EdgePropertyKeySegmentValues, +): EdgePropertyKeyParts | null { + if (!hasCompleteEdgePropertyKeySegments(values)) { + return null; + } + const keyParts = { + from: values.from, + to: values.to, + label: values.label, + propKey: values.propKey, + }; + if (!isLegacyEdgePropertyProjectionTarget(keyParts)) { + return null; + } + return keyParts; +} + +/** Returns true when all edge-property key segments are non-empty. */ +function hasCompleteEdgePropertyKeySegments( + values: EdgePropertyKeySegmentValues, +): values is CompleteEdgePropertyKeySegmentValues { + if (!isNonEmptyString(values.from)) { + return false; + } + if (!isNonEmptyString(values.to)) { + return false; + } + if (!isNonEmptyString(values.label)) { + return false; + } + if (!isNonEmptyString(values.propKey)) { + return false; + } + return true; +} + +/** Returns true for decoded non-empty string segments. */ +function isNonEmptyString(value: string | undefined): value is string { + return value !== undefined && value.length > 0; +} + +/** Filters edge registers hidden by edge rebirth. */ +function visibleEdgeRegister( + register: LWWRegister, + birthEvent: EventId | undefined, +): LWWRegister | null { + if (birthEvent === undefined || register.eventId === null) { + return register; + } + if (compareEventIds(register.eventId, birthEvent) < 0) { + return null; + } + return register; +} + +/** Compares edge property records by owner and key. */ +function compareEdgePropertyRecords( + left: VisibleEdgePropertyRecord, + right: VisibleEdgePropertyRecord, +): number { + return compareStrings(edgePropertyRecordSortKey(left), edgePropertyRecordSortKey(right)); +} + +/** Returns the deterministic sort key for an edge property record. */ +function edgePropertyRecordSortKey(record: VisibleEdgePropertyRecord): string { + return `${record.owner.id.toString()}:${record.key.toString()}`; +} diff --git a/src/domain/services/KeyCodec.ts b/src/domain/services/KeyCodec.ts index 30320706..50d914d5 100644 --- a/src/domain/services/KeyCodec.ts +++ b/src/domain/services/KeyCodec.ts @@ -10,6 +10,12 @@ import WarpError from '../errors/WarpError.ts'; +export { + CONTENT_MIME_PROPERTY_KEY, + CONTENT_PROPERTY_KEY, + CONTENT_SIZE_PROPERTY_KEY, +} from '../graph/LegacyContentPropertyKeys.ts'; + /** Field separator used in all encoded keys. */ export const FIELD_SEPARATOR = '\0'; @@ -19,24 +25,6 @@ export const FIELD_SEPARATOR = '\0'; */ export const EDGE_PROP_PREFIX = '\x01'; -/** - * Well-known property key for content attachment. - * Stores a content-addressed blob OID as the property value. - */ -export const CONTENT_PROPERTY_KEY = '_content'; - -/** - * Well-known property key for attached content MIME metadata. - * Stores a MIME type hint for the attached logical content referenced by `_content`. - */ -export const CONTENT_MIME_PROPERTY_KEY = '_content.mime'; - -/** - * Well-known property key for attached content byte-size metadata. - * Stores the byte length of the attached logical content referenced by `_content`. - */ -export const CONTENT_SIZE_PROPERTY_KEY = '_content.size'; - /** * Reserved node ID prefix for substrate-internal effect entities. * Observers match this prefix to discover effect nodes. diff --git a/src/domain/services/LegacyPropertyProjectionTarget.ts b/src/domain/services/LegacyPropertyProjectionTarget.ts new file mode 100644 index 00000000..2413ff40 --- /dev/null +++ b/src/domain/services/LegacyPropertyProjectionTarget.ts @@ -0,0 +1,41 @@ +import { + EDGE_PROP_PREFIX, + FIELD_SEPARATOR, +} from './KeyCodec.ts'; + +export type LegacyEdgePropertyProjectionTarget = { + readonly from: string; + readonly to: string; + readonly label: string; +}; + +/** Returns true when a public node-property projection target can be a graph node id. */ +export function isLegacyNodePropertyProjectionTarget(nodeId: string): boolean { + return isLegacyNodeSegment(nodeId); +} + +/** Returns true when a public edge-property projection target can be a graph edge identity. */ +export function isLegacyEdgePropertyProjectionTarget( + edge: LegacyEdgePropertyProjectionTarget, +): boolean { + return isLegacyNodeSegment(edge.from) + && isLegacyNodeSegment(edge.to) + && isLegacyEdgeTypeSegment(edge.label); +} + +/** Returns true for a node-id segment that runtime graph records can represent. */ +function isLegacyNodeSegment(value: string): boolean { + return isNonEmptyLegacySegment(value) && !value.startsWith(EDGE_PROP_PREFIX); +} + +/** Returns true for an edge-type segment that runtime graph records can represent. */ +function isLegacyEdgeTypeSegment(value: string): boolean { + return isNonEmptyLegacySegment(value); +} + +/** Returns true for a non-empty legacy segment without key separators. */ +function isNonEmptyLegacySegment(value: string): boolean { + return typeof value === 'string' + && value.length > 0 + && !value.includes(FIELD_SEPARATOR); +} diff --git a/src/domain/services/NodePropertyProjection.ts b/src/domain/services/NodePropertyProjection.ts new file mode 100644 index 00000000..8af188ba --- /dev/null +++ b/src/domain/services/NodePropertyProjection.ts @@ -0,0 +1,184 @@ +import LegacyNodePropertyKey from '../graph/LegacyNodePropertyKey.ts'; +import LegacyPropertyValue from '../graph/LegacyPropertyValue.ts'; +import NodeRecord from '../graph/NodeRecord.ts'; +import VisibleNodePropertyRecord from '../graph/VisibleNodePropertyRecord.ts'; +import WarpError from '../errors/WarpError.ts'; +import { + EDGE_PROP_PREFIX, + FIELD_SEPARATOR, + encodePropKey, +} from './KeyCodec.ts'; +import { isLegacyNodePropertyProjectionTarget } from './LegacyPropertyProjectionTarget.ts'; +import WarpState from './state/WarpState.ts'; +import { compareStrings } from '../utils/StringComparison.ts'; +import type { LWWRegister } from '../crdt/LWW.ts'; +import type NodeId from '../graph/NodeId.ts'; +import type { PropValue } from '../types/PropValue.ts'; + +type NodePropertyKeyParts = { + readonly nodeId: string; + readonly propKey: string; +}; + +/** Projects visible legacy node properties into compatibility records. */ +export default class NodePropertyProjection { + /** Returns all visible node property records in deterministic order. */ + static fromState(state: WarpState): readonly VisibleNodePropertyRecord[] { + const checkedState = requireWarpState(state); + const records: VisibleNodePropertyRecord[] = []; + for (const [encodedKey, register] of checkedState.prop) { + const record = nodePropertyRecordForRegister(checkedState, encodedKey, register); + if (record !== null) { + records.push(record); + } + } + records.sort(compareNodePropertyRecords); + return Object.freeze(records); + } + + /** Returns visible property records for one node in deterministic order. */ + static forNode(state: WarpState, nodeId: string | NodeId): readonly VisibleNodePropertyRecord[] { + const checkedState = requireWarpState(state); + const owner = nodeRecordForProjectionTarget(checkedState, nodeId); + if (owner === null) { + return Object.freeze([]); + } + return NodePropertyProjection.forNodeRecord(checkedState, owner); + } + + /** Returns visible property records for one runtime-backed node owner. */ + static forNodeRecord( + state: WarpState, + owner: NodeRecord, + ): readonly VisibleNodePropertyRecord[] { + const checkedState = requireWarpState(state); + const checkedOwner = requireNodeRecord(owner); + const records: VisibleNodePropertyRecord[] = []; + const ownerKeyPrefix = encodePropKey(checkedOwner.id.toString(), ''); + for (const [encodedKey, register] of checkedState.prop) { + if (!encodedKey.startsWith(ownerKeyPrefix)) { + continue; + } + const record = nodePropertyRecordForOwnerRegister(checkedOwner, encodedKey, register); + if (record !== null) { + records.push(record); + } + } + records.sort(compareNodePropertyRecords); + return Object.freeze(records); + } +} + +/** Requires a runtime-backed WarpState source. */ +function requireWarpState(state: WarpState): WarpState { + if (!(state instanceof WarpState)) { + throw new WarpError('NodePropertyProjection source must be a WarpState', 'E_VALIDATION'); + } + return state; +} + +/** Requires a runtime-backed node record owner. */ +function requireNodeRecord(owner: NodeRecord): NodeRecord { + if (!(owner instanceof NodeRecord)) { + throw new WarpError('NodePropertyProjection owner must be a NodeRecord', 'E_VALIDATION'); + } + return owner; +} + +/** Resolves a projection target without throwing on public miss carriers. */ +function nodeRecordForProjectionTarget(state: WarpState, nodeId: string | NodeId): NodeRecord | null { + if (typeof nodeId !== 'string') { + return state.getNodeRecord(nodeId); + } + if (!isLegacyNodePropertyProjectionTarget(nodeId)) { + return null; + } + return state.getNodeRecord(nodeId); +} + +/** Builds a node property record from one legacy state register. */ +function nodePropertyRecordForRegister( + state: WarpState, + encodedKey: string, + register: LWWRegister, +): VisibleNodePropertyRecord | null { + const keyParts = decodeVisibleNodePropertyKey(encodedKey); + if (keyParts === null) { + return null; + } + const owner = state.getNodeRecord(keyParts.nodeId); + if (owner === null) { + return null; + } + return new VisibleNodePropertyRecord({ + owner, + key: new LegacyNodePropertyKey(keyParts.propKey), + value: new LegacyPropertyValue(register.value), + }); +} + +/** Builds a node property record when it belongs to the requested owner. */ +function nodePropertyRecordForOwnerRegister( + owner: NodeRecord, + encodedKey: string, + register: LWWRegister, +): VisibleNodePropertyRecord | null { + const keyParts = decodeVisibleNodePropertyKey(encodedKey); + if (keyParts === null || keyParts.nodeId !== owner.id.toString()) { + return null; + } + return new VisibleNodePropertyRecord({ + owner, + key: new LegacyNodePropertyKey(keyParts.propKey), + value: new LegacyPropertyValue(register.value), + }); +} + +/** Decodes only well-formed legacy node property keys. */ +function decodeVisibleNodePropertyKey(encodedKey: string): NodePropertyKeyParts | null { + if (encodedKey.startsWith(EDGE_PROP_PREFIX)) { + return null; + } + const parts = encodedKey.split(FIELD_SEPARATOR); + return nodePropertyKeyPartsFromSegments(parts); +} + +/** Returns node property parts only when the legacy segments are well-formed. */ +function nodePropertyKeyPartsFromSegments(parts: readonly string[]): NodePropertyKeyParts | null { + if (parts.length !== 2) { + return null; + } + return nodePropertyKeyPartsFromValues(parts[0], parts[1]); +} + +/** Returns node property parts when both decoded values are non-empty. */ +function nodePropertyKeyPartsFromValues( + nodeId: string | undefined, + propKey: string | undefined, +): NodePropertyKeyParts | null { + if (!isNonEmptyString(nodeId)) { + return null; + } + if (!isNonEmptyString(propKey)) { + return null; + } + return { nodeId, propKey }; +} + +/** Returns true for decoded non-empty string segments. */ +function isNonEmptyString(value: string | undefined): value is string { + return value !== undefined && value.length > 0; +} + +/** Compares node property records by owner and key. */ +function compareNodePropertyRecords( + left: VisibleNodePropertyRecord, + right: VisibleNodePropertyRecord, +): number { + return compareStrings(nodePropertyRecordSortKey(left), nodePropertyRecordSortKey(right)); +} + +/** Returns the deterministic sort key for a node property record. */ +function nodePropertyRecordSortKey(record: VisibleNodePropertyRecord): string { + return `${record.owner.id.toString()}:${record.key.toString()}`; +} diff --git a/src/domain/services/controllers/QueryReads.ts b/src/domain/services/controllers/QueryReads.ts index 20bd4e25..a96e1d3f 100644 --- a/src/domain/services/controllers/QueryReads.ts +++ b/src/domain/services/controllers/QueryReads.ts @@ -6,25 +6,28 @@ */ import { - decodePropKey, - isEdgePropKey, - decodeEdgePropKey, encodeEdgeKey, decodeEdgeKey, } from '../KeyCodec.ts'; -import { compareEventIds, type EventId } from '../../utils/EventId.ts'; +import EdgePropertyProjection from '../EdgePropertyProjection.ts'; import { createSnapshotPropertyValues, createSnapshotPropValue, createSnapshotWarpState, } from '../ImmutableSnapshot.ts'; +import NodePropertyProjection from '../NodePropertyProjection.ts'; +import { + isLegacyEdgePropertyProjectionTarget, + isLegacyNodePropertyProjectionTarget, +} from '../LegacyPropertyProjectionTarget.ts'; +import EdgeRecord from '../../graph/EdgeRecord.ts'; import QueryError from '../../errors/QueryError.ts'; import type SnapshotWarpState from '../snapshot/SnapshotWarpState.ts'; import type WarpState from '../state/WarpState.ts'; import type NeighborProviderPort from '../../../ports/NeighborProviderPort.ts'; import type { NeighborEdge, NeighborOptions } from '../../../ports/NeighborProviderPort.ts'; -import type { LWWRegister } from '../../crdt/LWW.ts'; -import type { PropValue } from '../../types/PropValue.ts'; +import type VisibleEdgePropertyRecord from '../../graph/VisibleEdgePropertyRecord.ts'; +import type VisibleNodePropertyRecord from '../../graph/VisibleNodePropertyRecord.ts'; import type { SnapshotPropValue } from '../snapshot/SnapshotPropValue.ts'; import type { QueryReadHost } from './ReadGraphHost.ts'; @@ -51,9 +54,6 @@ type VisibleEdgeRead = { props: PropertyBag; }; -/** A property register stored on the CRDT state map. */ -type PropRegister = LWWRegister; - // ── Neighbor helpers ──────────────────────────────────────────────── function tagDirection(edges: NeighborEdge[], dir: DirectionTag): NeighborEntry[] { @@ -150,13 +150,16 @@ async function tryIndexedNodeProps(host: QueryReadHost, nodeId: string): Promise } function linearNodeProps(state: WarpState, nodeId: string): PropertyBag | null { - if (!state.nodeAlive.contains(nodeId)) { return null; } + if (!isLegacyNodePropertyProjectionTarget(nodeId)) { return null; } + const owner = state.getNodeRecord(nodeId); + if (owner === null) { return null; } + return nodePropertyBagFromRecords(NodePropertyProjection.forNodeRecord(state, owner)); +} + +function nodePropertyBagFromRecords(records: readonly VisibleNodePropertyRecord[]): PropertyBag { const props: MutablePropertyBag = {}; - for (const [propKey, register] of state.prop) { - const decoded = decodePropKey(propKey); - if (decoded.nodeId === nodeId) { - props[decoded.propKey] = createSnapshotPropValue(register.value); - } + for (const record of records) { + props[record.key.toString()] = createSnapshotPropValue(record.value.toPropValue()); } return Object.freeze(props); } @@ -167,45 +170,20 @@ export async function getEdgePropsImpl(host: QueryReadHost, edge: { from: string } function edgePropsFromState(state: WarpState, edge: { from: string; to: string; label: string }): PropertyBag | null { - const edgeKey = encodeEdgeKey(edge.from, edge.to, edge.label); - if (!state.edgeAlive.contains(edgeKey)) { return null; } - if (!state.nodeAlive.contains(edge.from) || !state.nodeAlive.contains(edge.to)) { return null; } - return collectEdgeProps(state, edge, edgeKey); + if (!isLegacyEdgePropertyProjectionTarget(edge)) { return null; } + const owner = state.getEdgeRecord(EdgeRecord.fromLegacyEdge(edge).id); + if (owner === null) { return null; } + return edgePropertyBagFromRecords(EdgePropertyProjection.forEdgeRecord(state, owner)); } -function isMatchingEdgeProp(d: { from: string; to: string; label: string }, edge: { from: string; to: string; label: string }): boolean { - return d.from === edge.from && d.to === edge.to && d.label === edge.label; -} - -function visibleEdgePropValue(params: { - propKey: string; - register: PropRegister; - edge: { from: string; to: string; label: string }; - birthEvent: EventId | undefined; -}): { key: string; value: SnapshotPropValue } | null { - const { propKey, register, edge, birthEvent } = params; - if (!isEdgePropKey(propKey)) { return null; } - const d = decodeEdgePropKey(propKey); - if (!isMatchingEdgeProp(d, edge)) { return null; } - if (isStaleEdgeProp(register, birthEvent)) { return null; } - return { key: d.propKey, value: createSnapshotPropValue(register.value) }; -} - -function collectEdgeProps(state: WarpState, edge: { from: string; to: string; label: string }, edgeKey: string): PropertyBag { - const birthEvent = state.edgeBirthEvent?.get(edgeKey); +function edgePropertyBagFromRecords(records: readonly VisibleEdgePropertyRecord[]): PropertyBag { const props: MutablePropertyBag = {}; - for (const [propKey, register] of state.prop) { - const entry = visibleEdgePropValue({ propKey, register, edge, birthEvent }); - if (entry) { props[entry.key] = entry.value; } + for (const record of records) { + props[record.key.toString()] = createSnapshotPropValue(record.value.toPropValue()); } return Object.freeze(props); } -function isStaleEdgeProp(register: PropRegister, birthEvent: EventId | undefined): boolean { - if (birthEvent === undefined || register.eventId === null) { return false; } - return compareEventIds(register.eventId, birthEvent) < 0; -} - export async function neighborsImpl(host: QueryReadHost, params: { nodeId: string; direction: 'outgoing' | 'incoming' | 'both'; edgeLabel?: string }): Promise { await host._ensureFreshState(); const indexed = await tryIndexedNeighbors(host, params); @@ -262,29 +240,29 @@ export async function getEdgesImpl(host: QueryReadHost): Promise { const result = new Map(); - for (const [propKey, register] of state.prop) { - if (!isEdgePropKey(propKey)) { continue; } - addEdgePropEntry({ state, propKey, register, result }); + for (const record of EdgePropertyProjection.fromState(state)) { + const edgeKey = encodeEdgeKey( + record.owner.from.toString(), + record.owner.to.toString(), + record.owner.typeId.toString(), + ); + addProjectedEdgePropEntry({ record, edgeKey, result }); } return result; } -function addEdgePropEntry(params: { - state: WarpState; - propKey: string; - register: PropRegister; +function addProjectedEdgePropEntry(params: { + record: VisibleEdgePropertyRecord; + edgeKey: string; result: Map; }): void { - const { state, propKey, register, result } = params; - const d = decodeEdgePropKey(propKey); - const ek = encodeEdgeKey(d.from, d.to, d.label); - if (isStaleEdgeProp(register, state.edgeBirthEvent?.get(ek))) { return; } - let bag = result.get(ek); + const { record, edgeKey, result } = params; + let bag = result.get(edgeKey); if (!bag) { bag = {}; - result.set(ek, bag); + result.set(edgeKey, bag); } - bag[d.propKey] = createSnapshotPropValue(register.value); + bag[record.key.toString()] = createSnapshotPropValue(record.value.toPropValue()); } function buildEdgeList(state: WarpState, edgeProps: Map): VisibleEdgeRead[] { diff --git a/src/domain/types/PropValue.ts b/src/domain/types/PropValue.ts index 7efe0242..9dbb0ab1 100644 --- a/src/domain/types/PropValue.ts +++ b/src/domain/types/PropValue.ts @@ -31,11 +31,10 @@ function isPropValueArray(value: CodecValue): value is PropValue[] { } function isPropValueObjectCandidate(value: CodecValue): value is { readonly [key: string]: CodecValue } { - return value !== null - && typeof value === 'object' - && !Array.isArray(value) - && !(value instanceof Uint8Array) - && !(value instanceof Date); + if (value === null || typeof value !== 'object') { + return false; + } + return isNonArrayPlainObject(value); } function isPropValueObject(value: CodecValue): value is { [key: string]: PropValue } { @@ -43,6 +42,14 @@ function isPropValueObject(value: CodecValue): value is { [key: string]: PropVal && Object.values(value).every((entry) => isPropValue(entry)); } +function isNonArrayPlainObject(value: object): boolean { + if (Array.isArray(value) || value instanceof Uint8Array) { + return false; + } + return Object.getPrototypeOf(value) === Object.prototype + || Object.getPrototypeOf(value) === null; +} + export function isPropValue(value: CodecValue): value is PropValue { return isScalarPropValue(value) || isPropValueArray(value) diff --git a/src/domain/utils/StringComparison.ts b/src/domain/utils/StringComparison.ts new file mode 100644 index 00000000..609d8346 --- /dev/null +++ b/src/domain/utils/StringComparison.ts @@ -0,0 +1,10 @@ +/** Compares protocol strings without locale-sensitive collation. */ +export function compareStrings(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} diff --git a/test/unit/domain/graph/LegacyPropertyProjection.test.ts b/test/unit/domain/graph/LegacyPropertyProjection.test.ts new file mode 100644 index 00000000..34e7c575 --- /dev/null +++ b/test/unit/domain/graph/LegacyPropertyProjection.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; + +import WarpError from '../../../../src/domain/errors/WarpError.ts'; +import { + EdgeRecord, + LegacyEdgePropertyKey, + LegacyNodePropertyKey, + LegacyPropertyProjection, + LegacyPropertyValue, + NodeRecord, + VisibleEdgePropertyRecord, + VisibleNodePropertyRecord, +} from '../../../../src/domain/graph/publicGraphSubstrate.ts'; +import { + CONTENT_MIME_PROPERTY_KEY, + CONTENT_PROPERTY_KEY, + CONTENT_SIZE_PROPERTY_KEY, +} from '../../../../src/domain/services/KeyCodec.ts'; + +describe('legacy property projection graph substrate nouns', () => { + it('classifies reserved content compatibility keys deterministically', () => { + const nodeContent = new LegacyNodePropertyKey(CONTENT_PROPERTY_KEY); + const edgeMime = new LegacyEdgePropertyKey(CONTENT_MIME_PROPERTY_KEY); + const edgeSize = new LegacyEdgePropertyKey(CONTENT_SIZE_PROPERTY_KEY); + const nodeUserKey = new LegacyNodePropertyKey('status'); + + expect(nodeContent.toString()).toBe(CONTENT_PROPERTY_KEY); + expect(nodeContent.classification()).toBe('content-oid'); + expect(nodeContent.isContentCompatibilityKey()).toBe(true); + expect(edgeMime.classification()).toBe('content-mime'); + expect(edgeMime.isContentCompatibilityKey()).toBe(true); + expect(edgeSize.classification()).toBe('content-size'); + expect(edgeSize.isContentCompatibilityKey()).toBe(true); + expect(nodeUserKey.classification()).toBe('user'); + expect(nodeUserKey.isContentCompatibilityKey()).toBe(false); + }); + + it('validates node and edge property keys as distinct runtime concepts', () => { + const nodeKey = new LegacyNodePropertyKey('status'); + const edgeKey = new LegacyEdgePropertyKey('status'); + + expect(nodeKey.equals(new LegacyNodePropertyKey('status'))).toBe(true); + expect(nodeKey.equals(new LegacyNodePropertyKey('owner'))).toBe(false); + expect(edgeKey.equals(new LegacyEdgePropertyKey('status'))).toBe(true); + expect(edgeKey.equals(new LegacyEdgePropertyKey('owner'))).toBe(false); + expect(Object.isFrozen(nodeKey)).toBe(true); + expect(Object.isFrozen(edgeKey)).toBe(true); + expect(() => new LegacyNodePropertyKey('')).toThrow(WarpError); + expect(() => new LegacyEdgePropertyKey('')).toThrow(WarpError); + expect(() => new LegacyNodePropertyKey('bad\0key')).toThrow(WarpError); + expect(() => new LegacyEdgePropertyKey('bad\0key')).toThrow(WarpError); + }); + + it('owns property values without exposing mutable source carriers', () => { + const bytes = new Uint8Array([1, 2, 3]); + const value = new LegacyPropertyValue(bytes); + + bytes[0] = 9; + + const stored = value.toPropValue(); + expect(stored).toBeInstanceOf(Uint8Array); + expect(Array.from(stored instanceof Uint8Array ? stored : new Uint8Array())).toEqual([1, 2, 3]); + expect(Object.isFrozen(value)).toBe(true); + // @ts-expect-error exercising runtime validation + expect(() => new LegacyPropertyValue(new InvalidPropertyCarrier())).toThrow(WarpError); + }); + + it('keeps node and edge visible property records separate', () => { + const nodeOwner = NodeRecord.fromLegacyNodeId('node:1'); + const edgeOwner = EdgeRecord.fromLegacyEdge({ from: 'node:1', to: 'node:2', label: 'rel' }); + const nodeKey = new LegacyNodePropertyKey('status'); + const edgeKey = new LegacyEdgePropertyKey('weight'); + const value = new LegacyPropertyValue('ready'); + + const nodeRecord = new VisibleNodePropertyRecord({ owner: nodeOwner, key: nodeKey, value }); + const edgeRecord = new VisibleEdgePropertyRecord({ owner: edgeOwner, key: edgeKey, value }); + + expect(nodeRecord.owner).toBe(nodeOwner); + expect(nodeRecord.key).toBe(nodeKey); + expect(nodeRecord.value).toBe(value); + expect(edgeRecord.owner).toBe(edgeOwner); + expect(edgeRecord.key).toBe(edgeKey); + expect(edgeRecord.value).toBe(value); + expect(Object.isFrozen(nodeRecord)).toBe(true); + expect(Object.isFrozen(edgeRecord)).toBe(true); + // @ts-expect-error exercising runtime validation + expect(() => new VisibleNodePropertyRecord({ owner: edgeOwner, key: nodeKey, value })).toThrow(WarpError); + // @ts-expect-error exercising runtime validation + expect(() => new VisibleEdgePropertyRecord({ owner: nodeOwner, key: edgeKey, value })).toThrow(WarpError); + }); + + it('groups visible property records by runtime owner identity', () => { + const nodeOwner = NodeRecord.fromLegacyNodeId('node:1'); + const otherNode = NodeRecord.fromLegacyNodeId('node:2'); + const edgeOwner = EdgeRecord.fromLegacyEdge({ from: 'node:1', to: 'node:2', label: 'rel' }); + const otherEdge = EdgeRecord.fromLegacyEdge({ from: 'node:2', to: 'node:1', label: 'rel' }); + const nodeRecord = new VisibleNodePropertyRecord({ + owner: nodeOwner, + key: new LegacyNodePropertyKey('status'), + value: new LegacyPropertyValue('ready'), + }); + const edgeRecord = new VisibleEdgePropertyRecord({ + owner: edgeOwner, + key: new LegacyEdgePropertyKey('weight'), + value: new LegacyPropertyValue(3), + }); + + const projection = new LegacyPropertyProjection({ + nodeProperties: [nodeRecord], + edgeProperties: [edgeRecord], + }); + + expect(projection.nodeProperties).toEqual([nodeRecord]); + expect(projection.edgeProperties).toEqual([edgeRecord]); + expect(projection.propertiesForNode(nodeOwner)).toEqual([nodeRecord]); + expect(projection.propertiesForNode(otherNode)).toEqual([]); + expect(projection.propertiesForEdge(edgeOwner)).toEqual([edgeRecord]); + expect(projection.propertiesForEdge(otherEdge)).toEqual([]); + expect(Object.isFrozen(projection)).toBe(true); + expect(Object.isFrozen(projection.nodeProperties)).toBe(true); + expect(Object.isFrozen(projection.edgeProperties)).toBe(true); + }); +}); + +class InvalidPropertyCarrier {} diff --git a/test/unit/domain/services/EdgePropertyProjection.test.ts b/test/unit/domain/services/EdgePropertyProjection.test.ts new file mode 100644 index 00000000..3f847539 --- /dev/null +++ b/test/unit/domain/services/EdgePropertyProjection.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; + +import { Dot } from '../../../../src/domain/crdt/Dot.ts'; +import { LWWRegister } from '../../../../src/domain/crdt/LWW.ts'; +import EdgePropertyProjection from '../../../../src/domain/services/EdgePropertyProjection.ts'; +import { + EDGE_PROP_PREFIX, + encodeEdgeKey, + encodeEdgePropKey, + encodePropKey, +} from '../../../../src/domain/services/KeyCodec.ts'; +import WarpState from '../../../../src/domain/services/state/WarpState.ts'; +import { EventId } from '../../../../src/domain/utils/EventId.ts'; + +describe('EdgePropertyProjection', () => { + it('projects visible edge properties as deterministic compatibility records', () => { + const state = WarpState.empty(); + addLiveNode(state, 'node:1', 1); + addLiveNode(state, 'node:2', 2); + addLiveNode(state, 'node:3', 3); + addLiveEdge(state, 'node:1', 'node:2', 'rel', 4); + addLiveEdge(state, 'node:3', 'node:2', 'rel', 5); + addRemovedEdge(state, 'node:2', 'node:3', 'rel', 6); + state.prop.set(encodeEdgePropKey('node:1', 'node:2', 'rel', 'weight'), register(7, 3)); + state.prop.set(encodeEdgePropKey('node:1', 'node:2', 'rel', '_content.size'), register(8, 42)); + state.prop.set(encodeEdgePropKey('node:3', 'node:2', 'rel', 'weight'), register(9, 7)); + state.prop.set(encodeEdgePropKey('node:2', 'node:3', 'rel', 'weight'), register(10, 11)); + state.prop.set(encodeEdgePropKey('missing', 'node:2', 'rel', 'weight'), register(11, 13)); + state.prop.set(encodePropKey('node:1', 'status'), register(12, 'ignored')); + state.prop.set(`${EDGE_PROP_PREFIX}node:1\0node:2\0rel\0bad\0extra`, register(13, 'ignored')); + state.prop.set(`${EDGE_PROP_PREFIX}${EDGE_PROP_PREFIX}reserved\0node:2\0rel\0bad`, register(14, 'ignored')); + + const records = EdgePropertyProjection.fromState(state); + + expect(records.map((record) => [ + record.owner.from.toString(), + record.owner.to.toString(), + record.owner.typeId.toString(), + record.key.toString(), + record.value.toPropValue(), + record.key.classification(), + ])).toEqual([ + ['node:1', 'node:2', 'rel', '_content.size', 42, 'content-size'], + ['node:1', 'node:2', 'rel', 'weight', 3, 'user'], + ['node:3', 'node:2', 'rel', 'weight', 7, 'user'], + ]); + expect(Object.isFrozen(records)).toBe(true); + }); + + it('hides edge property registers older than the current edge birth', () => { + const state = WarpState.empty(); + addLiveNode(state, 'node:1', 1); + addLiveNode(state, 'node:2', 2); + addLiveEdge(state, 'node:1', 'node:2', 'rel', 5); + state.prop.set(encodeEdgePropKey('node:1', 'node:2', 'rel', 'stale'), register(4, 'old')); + state.prop.set(encodeEdgePropKey('node:1', 'node:2', 'rel', 'fresh'), register(6, 'new')); + + const records = EdgePropertyProjection.forEdge(state, { + from: 'node:1', + to: 'node:2', + label: 'rel', + }); + + expect(records.map((record) => [ + record.key.toString(), + record.value.toPropValue(), + ])).toEqual([ + ['fresh', 'new'], + ]); + expect(EdgePropertyProjection.forEdge(state, { + from: 'node:2', + to: 'node:1', + label: 'rel', + })).toEqual([]); + expect(Object.isFrozen(records)).toBe(true); + }); + + it('keeps malformed public edge targets as empty projection reads', () => { + const state = WarpState.empty(); + addLiveNode(state, 'node:1', 1); + addLiveNode(state, 'node:2', 2); + addLiveEdge(state, 'node:1', 'node:2', 'rel', 3); + state.prop.set(encodeEdgePropKey('node:1', 'node:2', 'rel', 'weight'), register(4, 3)); + + expect(EdgePropertyProjection.forEdge(state, { + from: '', + to: 'node:2', + label: 'rel', + })).toEqual([]); + expect(EdgePropertyProjection.forEdge(state, { + from: `${EDGE_PROP_PREFIX}reserved`, + to: 'node:2', + label: 'rel', + })).toEqual([]); + expect(EdgePropertyProjection.forEdge(state, { + from: 'node:1', + to: 'bad\0node', + label: 'rel', + })).toEqual([]); + expect(EdgePropertyProjection.forEdge(state, { + from: 'node:1', + to: 'node:2', + label: '', + })).toEqual([]); + }); + + it('does not materialize unrelated edge owner records for targeted reads', () => { + const state = WarpState.empty(); + addLiveNode(state, 'node:1', 1); + addLiveNode(state, 'node:2', 2); + addLiveNode(state, 'node:3', 3); + addLiveEdge(state, 'node:1', 'node:2', 'rel', 4); + addLiveEdge(state, 'node:2', 'node:3', 'rel', 5); + state.prop.set(encodeEdgePropKey('node:1', 'node:2', 'rel', 'weight'), register(6, 3)); + const invalidRegister = LWWRegister.set(event(7), new InvalidPropertyCarrier()); + // @ts-expect-error exercising corrupt non-target state isolation + state.prop.set(encodeEdgePropKey('node:2', 'node:3', 'rel', 'bad'), invalidRegister); + + const records = EdgePropertyProjection.forEdge(state, { + from: 'node:1', + to: 'node:2', + label: 'rel', + }); + + expect(records.map((record) => [ + record.owner.from.toString(), + record.owner.to.toString(), + record.owner.typeId.toString(), + record.key.toString(), + record.value.toPropValue(), + ])).toEqual([ + ['node:1', 'node:2', 'rel', 'weight', 3], + ]); + }); +}); + +function addLiveNode(state: WarpState, nodeId: string, counter: number): void { + state.nodeAlive.add(nodeId, Dot.create('writer', counter)); +} + +function addLiveEdge( + state: WarpState, + from: string, + to: string, + label: string, + counter: number, +): void { + const edgeKey = encodeEdgeKey(from, to, label); + state.edgeAlive.add(edgeKey, Dot.create('writer', counter)); + state.edgeBirthEvent.set(edgeKey, event(counter)); +} + +function addRemovedEdge( + state: WarpState, + from: string, + to: string, + label: string, + counter: number, +): void { + const edgeKey = encodeEdgeKey(from, to, label); + state.edgeAlive.add(edgeKey, Dot.create('writer', counter)); + state.edgeBirthEvent.set(edgeKey, event(counter)); + state.edgeAlive.remove(state.edgeAlive.getDots(edgeKey)); +} + +function register(opIndex: number, value: string | number): LWWRegister { + return LWWRegister.set(event(opIndex), value); +} + +function event(opIndex: number): EventId { + return new EventId(1, 'writer', 'abcd', opIndex); +} + +class InvalidPropertyCarrier {} diff --git a/test/unit/domain/services/NodePropertyProjection.test.ts b/test/unit/domain/services/NodePropertyProjection.test.ts new file mode 100644 index 00000000..8aa36f63 --- /dev/null +++ b/test/unit/domain/services/NodePropertyProjection.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { Dot } from '../../../../src/domain/crdt/Dot.ts'; +import { LWWRegister } from '../../../../src/domain/crdt/LWW.ts'; +import NodePropertyProjection from '../../../../src/domain/services/NodePropertyProjection.ts'; +import { + EDGE_PROP_PREFIX, + encodeEdgePropKey, + encodePropKey, +} from '../../../../src/domain/services/KeyCodec.ts'; +import WarpState from '../../../../src/domain/services/state/WarpState.ts'; +import { EventId } from '../../../../src/domain/utils/EventId.ts'; + +describe('NodePropertyProjection', () => { + it('projects visible node properties as deterministic compatibility records', () => { + const state = WarpState.empty(); + state.nodeAlive.add('node:1', Dot.create('writer', 1)); + state.nodeAlive.add('node:2', Dot.create('writer', 2)); + state.nodeAlive.add('removed', Dot.create('writer', 3)); + state.nodeAlive.remove(state.nodeAlive.getDots('removed')); + state.prop.set(encodePropKey('node:1', 'status'), register(1, 'ready')); + state.prop.set(encodePropKey('node:1', '_content'), register(2, 'abc123')); + state.prop.set(encodePropKey('node:2', 'status'), register(3, 'waiting')); + state.prop.set(encodePropKey('removed', 'status'), register(4, 'gone')); + state.prop.set(encodePropKey('missing', 'status'), register(5, 'orphan')); + state.prop.set(encodeEdgePropKey('node:1', 'node:2', 'rel', 'weight'), register(6, 3)); + state.prop.set('node:1\0bad\0extra', register(7, 'ignored')); + + const records = NodePropertyProjection.fromState(state); + + expect(records.map((record) => [ + record.owner.id.toString(), + record.key.toString(), + record.value.toPropValue(), + record.key.classification(), + ])).toEqual([ + ['node:1', '_content', 'abc123', 'content-oid'], + ['node:1', 'status', 'ready', 'user'], + ['node:2', 'status', 'waiting', 'user'], + ]); + expect(Object.isFrozen(records)).toBe(true); + }); + + it('projects one visible node without exposing other owners', () => { + const state = WarpState.empty(); + state.nodeAlive.add('node:1', Dot.create('writer', 1)); + state.nodeAlive.add('node:2', Dot.create('writer', 2)); + state.prop.set(encodePropKey('node:1', 'status'), register(1, 'ready')); + state.prop.set(encodePropKey('node:2', 'status'), register(2, 'waiting')); + + const records = NodePropertyProjection.forNode(state, 'node:2'); + + expect(records.map((record) => [ + record.owner.id.toString(), + record.key.toString(), + record.value.toPropValue(), + ])).toEqual([ + ['node:2', 'status', 'waiting'], + ]); + expect(NodePropertyProjection.forNode(state, 'missing')).toEqual([]); + expect(Object.isFrozen(records)).toBe(true); + }); + + it('keeps malformed public node targets as empty projection reads', () => { + const state = WarpState.empty(); + state.nodeAlive.add('node:1', Dot.create('writer', 1)); + state.prop.set(encodePropKey('node:1', 'status'), register(1, 'ready')); + + expect(NodePropertyProjection.forNode(state, '')).toEqual([]); + expect(NodePropertyProjection.forNode(state, 'bad\0node')).toEqual([]); + expect(NodePropertyProjection.forNode(state, `${EDGE_PROP_PREFIX}reserved`)).toEqual([]); + }); + + it('does not materialize unrelated owner records for targeted reads', () => { + const state = WarpState.empty(); + state.nodeAlive.add('node:1', Dot.create('writer', 1)); + state.nodeAlive.add('node:2', Dot.create('writer', 2)); + state.prop.set(encodePropKey('node:1', 'status'), register(1, 'ready')); + const invalidRegister = LWWRegister.set(new EventId(1, 'writer', 'abcd', 2), new InvalidPropertyCarrier()); + // @ts-expect-error exercising corrupt non-target state isolation + state.prop.set(encodePropKey('node:2', 'bad'), invalidRegister); + + const records = NodePropertyProjection.forNode(state, 'node:1'); + + expect(records.map((record) => [ + record.owner.id.toString(), + record.key.toString(), + record.value.toPropValue(), + ])).toEqual([ + ['node:1', 'status', 'ready'], + ]); + }); +}); + +function register(opIndex: number, value: string | number): LWWRegister { + return LWWRegister.set(new EventId(1, 'writer', 'abcd', opIndex), value); +} + +class InvalidPropertyCarrier {} diff --git a/test/unit/domain/services/QueryReadsPropertyProjection.test.ts b/test/unit/domain/services/QueryReadsPropertyProjection.test.ts new file mode 100644 index 00000000..569842fc --- /dev/null +++ b/test/unit/domain/services/QueryReadsPropertyProjection.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; + +import { Dot } from '../../../../src/domain/crdt/Dot.ts'; +import { LWWRegister } from '../../../../src/domain/crdt/LWW.ts'; +import { + getEdgePropsImpl, + getEdgesImpl, + getNodePropsImpl, +} from '../../../../src/domain/services/controllers/QueryReads.ts'; +import type { QueryReadHost } from '../../../../src/domain/services/controllers/ReadGraphHost.ts'; +import { + EDGE_PROP_PREFIX, + encodeEdgeKey, + encodeEdgePropKey, + encodePropKey, +} from '../../../../src/domain/services/KeyCodec.ts'; +import WarpState from '../../../../src/domain/services/state/WarpState.ts'; +import type { PropValue } from '../../../../src/domain/types/PropValue.ts'; +import { EventId } from '../../../../src/domain/utils/EventId.ts'; + +describe('QueryReads property projection routing', () => { + it('formats node and edge properties from projection records', async () => { + const state = WarpState.empty(); + addLiveNode(state, 'node:1', 1); + addLiveNode(state, 'node:2', 2); + addLiveEdge(state, 'node:1', 'node:2', 'rel', 3); + state.prop.set(encodePropKey('node:1', 'status'), register(4, 'ready')); + state.prop.set(encodePropKey('node:1', '_content'), register(5, 'abc123')); + state.prop.set('node:1\0bad\0extra', register(6, 'ignored')); + state.prop.set(encodeEdgePropKey('node:1', 'node:2', 'rel', 'weight'), register(7, 3)); + state.prop.set(`${EDGE_PROP_PREFIX}node:1\0node:2\0rel\0bad\0extra`, register(8, 'ignored')); + + const host = hostForState(state); + + await expect(getNodePropsImpl(host, 'node:1')).resolves.toEqual({ + _content: 'abc123', + status: 'ready', + }); + await expect(getEdgePropsImpl(host, { + from: 'node:1', + to: 'node:2', + label: 'rel', + })).resolves.toEqual({ weight: 3 }); + await expect(getEdgesImpl(host)).resolves.toEqual([ + { + from: 'node:1', + to: 'node:2', + label: 'rel', + props: { weight: 3 }, + }, + ]); + }); + + it('keeps malformed public property queries as misses', async () => { + const state = WarpState.empty(); + addLiveNode(state, 'node:1', 1); + addLiveNode(state, 'node:2', 2); + addLiveEdge(state, 'node:1', 'node:2', 'rel', 3); + const host = hostForState(state); + + await expect(getNodePropsImpl(host, '')).resolves.toBeNull(); + await expect(getNodePropsImpl(host, 'bad\0node')).resolves.toBeNull(); + await expect(getNodePropsImpl(host, `${EDGE_PROP_PREFIX}reserved`)).resolves.toBeNull(); + await expect(getEdgePropsImpl(host, { + from: '', + to: 'node:2', + label: 'rel', + })).resolves.toBeNull(); + await expect(getEdgePropsImpl(host, { + from: `${EDGE_PROP_PREFIX}reserved`, + to: 'node:2', + label: 'rel', + })).resolves.toBeNull(); + await expect(getEdgePropsImpl(host, { + from: 'node:1', + to: 'bad\0node', + label: 'rel', + })).resolves.toBeNull(); + await expect(getEdgePropsImpl(host, { + from: 'node:1', + to: 'node:2', + label: '', + })).resolves.toBeNull(); + }); +}); + +function hostForState(state: WarpState): QueryReadHost { + return { + _cachedState: state, + _autoMaterialize: true, + _propertyReader: null, + _logicalIndex: null, + _materializedGraph: null, + _ensureFreshState: async () => {}, + }; +} + +function addLiveNode(state: WarpState, nodeId: string, counter: number): void { + state.nodeAlive.add(nodeId, Dot.create('writer', counter)); +} + +function addLiveEdge( + state: WarpState, + from: string, + to: string, + label: string, + counter: number, +): void { + const edgeKey = encodeEdgeKey(from, to, label); + state.edgeAlive.add(edgeKey, Dot.create('writer', counter)); + state.edgeBirthEvent.set(edgeKey, event(counter)); +} + +function register(opIndex: number, value: PropValue): LWWRegister { + return LWWRegister.set(event(opIndex), value); +} + +function event(opIndex: number): EventId { + return new EventId(1, 'writer', 'abcd', opIndex); +}