diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index 088e31b99..6f3b17791 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -21,6 +21,7 @@ jobs: with: args: >- --config .lychee.toml + --include-fragments '**/*.md' fail: true format: markdown diff --git a/.github/workflows/main-push-release-branch-check.yml b/.github/workflows/main-push-release-branch-check.yml index 79811d407..89465618b 100644 --- a/.github/workflows/main-push-release-branch-check.yml +++ b/.github/workflows/main-push-release-branch-check.yml @@ -88,6 +88,7 @@ jobs: with: args: >- --config .lychee.toml + --include-fragments '**/*.md' fail: true format: markdown diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e032f5cc..1312ac0ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,6 +109,7 @@ jobs: with: args: >- --config .lychee.toml + --include-fragments '**/*.md' fail: true format: markdown diff --git a/.lychee.toml b/.lychee.toml index d99526caf..e7a34f261 100644 --- a/.lychee.toml +++ b/.lychee.toml @@ -11,9 +11,6 @@ offline = true # Resolve bare links like [Guide](docs/GUIDE) → docs/GUIDE.md fallback_extensions = ["md", "html"] -# Check #heading fragment anchors within markdown files -include_fragments = true - # No interactive progress bar (clean output in hooks/CI) no_progress = true diff --git a/CHANGELOG.md b/CHANGELOG.md index fd05f4151..4bcf2f004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added root `intent` builders, the runtime-backed `Intent` noun, + `Timeline.write(intent)`, and `WriteReceipt` results with the public + `ReceiptOutcome` axis: `accepted`, `obstructed`, `conflicted`, + `underdetermined`, and `rejected`. +- Added root `reading` builders, the runtime-backed `Reading` noun, + `Timeline.read(reading)`, and receipt-bearing `ReadingResult` values for + first-use property and node-existence reads. +- Added `Timeline.draft(name)`, draft writes, `Timeline.previewJoin(draft)`, + and `Timeline.join(draft)` with join receipts for first-use speculative + workflows. + ### Changed - Added the v19 `openWarp()` product opener plus `Warp` and `Timeline` diff --git a/README.md b/README.md index ecfea2316..538fb4d6c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@

A Git-native runtime for shared causal history.

-

Offline-first, multi-writer, deterministic, and built for provenance-aware intent writes, timeline reads, and receipts.

+

Write intents. Read timelines. Keep receipts.

+

Offline-first, multi-writer, deterministic, and built for provenance-aware applications.

@@ -51,11 +52,10 @@ replay-backed so callers receive complete diff and provenance data. See [CHANGELOG.md](CHANGELOG.md) for the full in-repository release notes. -## Planned v19 API Direction +## v19 First-Use API -This is the public contract the v19 facade slices are moving toward. It is the -direction for new application code, not an endorsement of the deprecated v18 -compatibility API. +This is the public contract new application code should start from. The +deprecated v18 graph-first API remains available only for migration. ```typescript import { openWarp, intent, reading } from "@git-stunts/git-warp"; @@ -70,6 +70,10 @@ const warp = await openWarp({ const events = await warp.timeline("events"); +await events.write(intent.node.add({ + subject: "user:alice", +})); + const write = await events.write(intent.property.set({ subject: "user:alice", key: "role", @@ -80,6 +84,9 @@ const role = await events.read(reading.property({ subject: "user:alice", key: "role", })); + +console.log(role.value); +console.log(role.receipt); ``` The v18 graph-first API remains only under `@git-stunts/git-warp/legacy`. That diff --git a/docs/migrations/v19/README.md b/docs/migrations/v19/README.md index c5ca1b236..8cb3891fe 100644 --- a/docs/migrations/v19/README.md +++ b/docs/migrations/v19/README.md @@ -122,18 +122,14 @@ const receipt = await events.write( switch (receipt.outcome) { case 'accepted': + console.log(receipt.patchSha); break; case 'obstructed': - await preserveRepairWork(receipt.repairHints); - break; case 'conflicted': - await openConflictResolution(receipt.conflicts); - break; case 'underdetermined': - await gatherSupport(receipt.evidence); - break; case 'rejected': - throw new Error(receipt.reason); + console.log(receipt.reason ?? receipt.outcome); + break; } ``` @@ -253,7 +249,7 @@ and joins first. | `WarpWorldline` | root `Timeline` | public handle rename | | `GitGraphAdapter` | `storage` `GitStorageAdapter` | graph name removed | | `InMemoryGraphAdapter` | `storage` `MemoryStorageAdapter` | graph name removed | -| `GraphPersistencePort` | `storage` `StorageAdapter` | public storage contract | +| `GraphPersistencePort` | root `WarpStorage` for app options; `legacy` for the old port | public storage contract is facade-shaped | | `commit((patch) => ...)` | `timeline.write(intent.*)` | receipt-returning | | `PatchBuilder` | deprecated `legacy` | replace with intent builders | | `PatchSession` | deprecated `legacy` | replace with receipt-returning writes | @@ -288,18 +284,14 @@ const receipt = await timeline.write(intent.property.set({ switch (receipt.outcome) { case 'accepted': + console.log(receipt.patchSha); break; case 'obstructed': - await repair(receipt.repairHints); - break; case 'conflicted': - await resolve(receipt.conflicts); - break; case 'underdetermined': - await gatherSupport(receipt.evidence); - break; case 'rejected': - throw new Error(receipt.reason); + console.log(receipt.reason ?? receipt.outcome); + break; } ``` @@ -313,8 +305,8 @@ underdetermined rejected ``` -Operation names such as `join`, `sync`, and `read` belong in -`receipt.operation`, not in `receipt.outcome`. +Operation names such as `join`, `sync`, and `read` belong in distinct receipt +classes or operation fields, not in `receipt.outcome`. ## Suggested Upgrade Sequence diff --git a/docs/topics/api/README.md b/docs/topics/api/README.md index 5cbd318aa..84715a531 100644 --- a/docs/topics/api/README.md +++ b/docs/topics/api/README.md @@ -110,6 +110,10 @@ const warp = await openWarp({ const timeline = await warp.timeline('events'); +await timeline.write(intent.node.add({ + subject: 'user:alice', +})); + const write = await timeline.write( intent.property.set({ subject: 'user:alice', @@ -120,18 +124,14 @@ const write = await timeline.write( switch (write.outcome) { case 'accepted': + console.log(write.patchSha); break; case 'obstructed': - await preserveRepairWork(write.repairHints); - break; case 'conflicted': - await openConflictResolution(write.conflicts); - break; case 'underdetermined': - await gatherSupport(write.evidence); - break; case 'rejected': - throw new Error(write.reason); + console.log(write.reason ?? write.outcome); + break; } const role = await timeline.read( @@ -183,35 +183,30 @@ intent.property.set({ value: 'admin', }); -intent.entity.create({ +intent.node.add({ subject: 'user:alice', }); -intent.link.add({ +intent.edge.add({ from: 'user:alice', to: 'team:ops', label: 'memberOf', }); ``` -Custom intent definitions are still important, but they should be a named -escape hatch: +Domain-specific builders can be layered by application code, but the root API +should keep returning runtime-backed `Intent` values: ```typescript -const assignRole = intent.define({ - type: 'user.role.assign', - schema: { - subject: 'user:*', - payload: { - role: 'string', - }, - }, -}); +function assignRole(subject: string, role: string) { + return intent.property.set({ + subject, + key: 'role', + value: role, + }); +} -await timeline.write(assignRole({ - subject: 'user:alice', - payload: { role: 'admin' }, -})); +await timeline.write(assignRole('user:alice', 'admin')); ``` The builder output should be a runtime-backed value, not loose shape trust. @@ -238,14 +233,15 @@ In v18, users often need to call `prepareOpticBasis()`, capture a coordinate, and read through `optic()`. That discipline is correct, but it should be internal ceremony for the first-use path. -The v19 `timeline.read(reading)` flow can still lower to an optic internally: +The v19 `timeline.read(reading)` flow is the public shape. Advanced bounded +read paths can still lower readings to optics internally: 1. validate the `Reading`; 2. lower `Reading` to `Optic`; 3. prepare or check an optic basis; 4. capture the observer position; 5. execute the bounded read; -6. return `ReadingResult` and `Receipt`. +6. return `ReadingResult` and `ReadReceipt`. Operational uncertainty should return receipt outcomes where possible. Programmer errors, corruption, impossible states, and violated invariants may @@ -256,22 +252,18 @@ still throw typed errors. Receipts should become the normal control-flow object. ```typescript -const receipt = await timeline.write(assignRole); +const receipt = await timeline.write(assignRole('user:alice', 'admin')); switch (receipt.outcome) { case 'accepted': + console.log(receipt.patchSha); break; case 'obstructed': - await repair(receipt.repairHints); - break; case 'conflicted': - await resolve(receipt.conflicts); - break; case 'underdetermined': - await gatherMoreEvidence(receipt.evidence); - break; case 'rejected': - throw new Error(receipt.reason); + console.log(receipt.reason ?? receipt.outcome); + break; } ``` @@ -288,14 +280,8 @@ rejected Do not mix operation types into outcomes. `joined`, `synced`, `staged`, and `materialized` describe operations or states, not the outcome axis. -Use: - -```text -receipt.operation -receipt.outcome -``` - -not one overloaded status string. +Use separate result classes (`WriteReceipt`, `ReadReceipt`, and later join +receipts), not one overloaded status string. ## Timelines, Drafts, And Joins @@ -310,7 +296,7 @@ Strand -> speculative lane Braid -> deterministic lane composition ``` -Public speculative work should read like this: +Public speculative work reads like this: ```typescript const draft = await timeline.draft('try-admin-role'); @@ -338,7 +324,7 @@ avoids boolean-trap API design and makes the two phases explicit. ## Tick And Coordinate -Use `Tick` for ergonomic public time travel: +Future ergonomic public time travel should use `Tick`: ```typescript const tick = await timeline.tick(); @@ -351,11 +337,12 @@ const roleAtTick = await timeline.at(tick).read( ); ``` -Use `Coordinate` for formal evidence and proof posture: +Use `Coordinate` for formal evidence and proof posture on advanced surfaces. +Do not make first-use application examples import coordinate machinery. ```text -receipt.coordinate -receipt.evidence.observerCoordinate +public: tick handle +advanced: coordinate evidence ``` That split lets casual users keep a simple handle while advanced users and diff --git a/docs/topics/reference.md b/docs/topics/reference.md index c5ae9429a..acc0f1cba 100644 --- a/docs/topics/reference.md +++ b/docs/topics/reference.md @@ -26,95 +26,530 @@ public API export, CLI command, package entrypoint, or public error class. | JSR export | `./legacy` | `./legacy.ts` | `jsr.json#L13` | | JSR export | `./sha1sync` | `./src/infrastructure/adapters/sha1sync.ts` | `jsr.json#L14` | -## Root API export modules +## Root API export surface + +First-use product API: `openWarp`, `intent`, `reading`, timelines, and receipts. + +### Export modules | Module | Kind | Source | | --- | --- | --- | -| `./src/domain/memory/index.ts` | export * | `index.ts#L23` | +| `./src/domain/memory/index.ts` | export * | `index.ts#L55` | -## Root API value exports +### Value exports -Source: `index.ts`. Count: 63. +Source: `index.ts`. Count: 73. ```text -AlfredOperationPolicyAdapter @ index.ts#L36 -AuditError @ index.ts#L39 -BunHttpAdapter @ index.ts#L70 -canonicalEmissionJson @ index.ts#L86 -canonicalObservationJson @ index.ts#L92 -CasContentEncryptionPolicy @ index.ts#L30 -checkAborted @ index.ts#L72 -ChunkEffectSink @ index.ts#L102 -ConsoleEffectSink @ index.ts#L101 -ConsoleLogger @ index.ts#L64 -ContinuumArtifactAuthorityError @ index.ts#L40 -createDeliveryObservation @ index.ts#L91 -createEffectEmission @ index.ts#L85 -createExternalizationPolicy @ index.ts#L95 -createTickReceipt @ index.ts#L76 -createTimeoutSignal @ index.ts#L72 -CryptoPort @ index.ts#L66 -DELIVERY_MODES @ index.ts#L87 -DELIVERY_OUTCOMES @ index.ts#L88 -DenoHttpAdapter @ index.ts#L71 -EffectPipeline @ index.ts#L83 -EffectSinkPort @ index.ts#L81 -EncryptionError @ index.ts#L41 -ForkError @ index.ts#L42 -HealthCheckService @ index.ts#L61 -HealthStatus @ index.ts#L61 -HttpServerPort @ index.ts#L67 -IndexError @ index.ts#L43 -INSPECT_LENS @ index.ts#L98 -LIVE_LENS @ index.ts#L96 -LoggerPort @ index.ts#L62 -LogLevel @ index.ts#L64 -MemoryBudgetError @ index.ts#L44 -MultiplexSink @ index.ts#L82 -NodeCryptoAdapter @ index.ts#L68 -NoOpEffectSink @ index.ts#L100 -NoOpLogger @ index.ts#L63 -NoopOperationPolicyAdapter @ index.ts#L37 +AlfredOperationPolicyAdapter @ index.ts#L68 +AuditError @ index.ts#L71 +BunHttpAdapter @ index.ts#L102 +canonicalEmissionJson @ index.ts#L118 +canonicalObservationJson @ index.ts#L124 +CasContentEncryptionPolicy @ index.ts#L62 +checkAborted @ index.ts#L104 +ChunkEffectSink @ index.ts#L134 +ConsoleEffectSink @ index.ts#L133 +ConsoleLogger @ index.ts#L96 +ContinuumArtifactAuthorityError @ index.ts#L72 +createDeliveryObservation @ index.ts#L123 +createEffectEmission @ index.ts#L117 +createExternalizationPolicy @ index.ts#L127 +createTickReceipt @ index.ts#L108 +createTimeoutSignal @ index.ts#L104 +CryptoPort @ index.ts#L98 +DELIVERY_MODES @ index.ts#L119 +DELIVERY_OUTCOMES @ index.ts#L120 +DenoHttpAdapter @ index.ts#L103 +DraftTimeline @ index.ts#L19 +EffectPipeline @ index.ts#L115 +EffectSinkPort @ index.ts#L113 +EncryptionError @ index.ts#L73 +ForkError @ index.ts#L74 +HealthCheckService @ index.ts#L93 +HealthStatus @ index.ts#L93 +HttpServerPort @ index.ts#L99 +IndexError @ index.ts#L75 +INSPECT_LENS @ index.ts#L130 +intent @ index.ts#L22 +Intent @ index.ts#L23 +JoinReceipt @ index.ts#L24 +JoinResult @ index.ts#L25 +LIVE_LENS @ index.ts#L128 +LoggerPort @ index.ts#L94 +LogLevel @ index.ts#L96 +MemoryBudgetError @ index.ts#L76 +MultiplexSink @ index.ts#L114 +NodeCryptoAdapter @ index.ts#L100 +NoOpEffectSink @ index.ts#L132 +NoOpLogger @ index.ts#L95 +NoopOperationPolicyAdapter @ index.ts#L69 openWarp @ index.ts#L18 -OperationAbortedError @ index.ts#L45 -OperationPolicyExhaustedError @ index.ts#L46 -OperationPolicyPort @ index.ts#L25 -OperationPolicyTimeoutError @ index.ts#L47 -PatchError @ index.ts#L48 -QueryError @ index.ts#L49 -REPLAY_LENS @ index.ts#L97 -SchemaUnsupportedError @ index.ts#L50 -ShardCorruptionError @ index.ts#L51 -ShardLoadError @ index.ts#L52 -ShardValidationError @ index.ts#L53 -StorageError @ index.ts#L54 -StrandError @ index.ts#L55 -SyncError @ index.ts#L56 -SyncSecret @ index.ts#L73 -TICK_RECEIPT_OP_TYPES @ index.ts#L78 -TICK_RECEIPT_RESULT_TYPES @ index.ts#L79 -tickReceiptCanonicalJson @ index.ts#L77 -Timeline @ index.ts#L20 -TraversalError @ index.ts#L57 -Warp @ index.ts#L19 -WebCryptoAdapter @ index.ts#L69 -WormholeError @ index.ts#L58 -WriterError @ index.ts#L65 +OperationAbortedError @ index.ts#L77 +OperationPolicyExhaustedError @ index.ts#L78 +OperationPolicyPort @ index.ts#L57 +OperationPolicyTimeoutError @ index.ts#L79 +PatchError @ index.ts#L80 +QueryError @ index.ts#L81 +reading @ index.ts#L26 +Reading @ index.ts#L27 +ReadingResult @ index.ts#L28 +ReadReceipt @ index.ts#L29 +REPLAY_LENS @ index.ts#L129 +SchemaUnsupportedError @ index.ts#L82 +ShardCorruptionError @ index.ts#L83 +ShardLoadError @ index.ts#L84 +ShardValidationError @ index.ts#L85 +StorageError @ index.ts#L86 +StrandError @ index.ts#L87 +SyncError @ index.ts#L88 +SyncSecret @ index.ts#L105 +TICK_RECEIPT_OP_TYPES @ index.ts#L110 +TICK_RECEIPT_RESULT_TYPES @ index.ts#L111 +tickReceiptCanonicalJson @ index.ts#L109 +Timeline @ index.ts#L21 +TraversalError @ index.ts#L89 +Warp @ index.ts#L20 +WebCryptoAdapter @ index.ts#L101 +WormholeError @ index.ts#L90 +WriteReceipt @ index.ts#L30 +WriterError @ index.ts#L97 +``` + +### Type exports + +Source: `index.ts`. Count: 32. + +```text +CasContentEncryptionDiagnostics @ index.ts#L64 +CasContentEncryptionScheme @ index.ts#L65 +CasResolvedVaultKeyOptions @ index.ts#L66 +EdgeIntentFields @ index.ts#L33 +EdgePropertyIntentFields @ index.ts#L34 +IntentBuilders @ index.ts#L40 +IntentDescriptor @ index.ts#L35 +IntentKind @ index.ts#L36 +JoinMode @ index.ts#L41 +JoinOptions @ index.ts#L43 +JoinPolicy @ index.ts#L43 +JoinReceiptOptions @ index.ts#L41 +JoinReceiptOutcome @ index.ts#L41 +JoinResultOptions @ index.ts#L42 +NodeIntentFields @ index.ts#L37 +NodeReadingFields @ index.ts#L45 +OpenWarpOptions @ index.ts#L31 +OperationPolicyExecuteOptions @ index.ts#L59 +OperationRetryDecision @ index.ts#L60 +PropertyIntentFields @ index.ts#L38 +PropertyReadingFields @ index.ts#L46 +ReadingBuilders @ index.ts#L50 +ReadingDescriptor @ index.ts#L47 +ReadingKind @ index.ts#L48 +ReadingResultOptions @ index.ts#L51 +ReadingValue @ index.ts#L51 +ReadReceiptOptions @ index.ts#L52 +ReadReceiptOutcome @ index.ts#L52 +ReceiptOutcome @ index.ts#L53 +SyncRateLimitConfig @ index.ts#L106 +WarpStorage @ index.ts#L31 +WriteReceiptOptions @ index.ts#L53 +``` + +## Storage export surface + +Supported persistence and crypto adapters for first-use applications. + +### Value exports + +Source: `storage.ts`. Count: 5. + +```text +CasContentEncryptionPolicy @ storage.ts#L13 +GitStorageAdapter @ storage.ts#L9 +MemoryStorageAdapter @ storage.ts#L10 +NodeCryptoAdapter @ storage.ts#L11 +WebCryptoAdapter @ storage.ts#L12 +``` + +### Type exports + +Source: `storage.ts`. Count: 6. + +```text +CasContentEncryptionDiagnostics @ storage.ts#L15 +CasContentEncryptionScheme @ storage.ts#L16 +CasResolvedVaultKeyOptions @ storage.ts#L17 +CollectableStream @ storage.ts#L20 +GitError @ storage.ts#L21 +GitPlumbing @ storage.ts#L22 +``` + +## Advanced export surface + +Formal WARP and Continuum concepts for expert use; not first-use root API. + +### Export modules + +| Module | Kind | Source | +| --- | --- | --- | +| `./src/continuumExports.ts` | export * | `advanced.ts#L42` | + +### Value exports + +Source: `advanced.ts`. Count: 31. + +```text +BoundedSupportRule @ advanced.ts#L10 +CausalIndexPlan @ advanced.ts#L11 +composeWormholes @ advanced.ts#L12 +createWormhole @ advanced.ts#L13 +deserializeWormhole @ advanced.ts#L14 +LiveSelector @ advanced.ts#L15 +Observer @ advanced.ts#L16 +ObserverAccumulation @ advanced.ts#L17 +ObserverBasis @ advanced.ts#L18 +ObserverEmission @ advanced.ts#L19 +ObserverPlan @ advanced.ts#L20 +ObserverReadingEnvelope @ advanced.ts#L21 +openAperture @ advanced.ts#L22 +Optic @ advanced.ts#L23 +OpticAperturePosture @ advanced.ts#L24 +OpticBasisPosture @ advanced.ts#L25 +OpticCoordinatePosture @ advanced.ts#L26 +OpticSupportRule @ advanced.ts#L27 +ProjectionHandle @ advanced.ts#L28 +RejectedZKWormhole @ advanced.ts#L29 +replayWormhole @ advanced.ts#L30 +serializeWormhole @ advanced.ts#L31 +StrandSelector @ advanced.ts#L32 +SupportFragmentPlan @ advanced.ts#L33 +VerifiedZKWormhole @ advanced.ts#L34 +verifyZKWormhole @ advanced.ts#L35 +WarpWorldlineCoordinate @ advanced.ts#L36 +WarpWorldlineOpticBasis @ advanced.ts#L37 +WorldlineSelector @ advanced.ts#L38 +ZKWormholeEdge @ advanced.ts#L39 +ZKWormholeProofVerifierPort @ advanced.ts#L40 +``` + +### Type exports + +Source: `advanced.ts`. Count: 25. + +```text +Aperture @ advanced.ts#L44 +ApertureOpeningVerificationResult @ advanced.ts#L45 +BoundedSupportDirection @ advanced.ts#L46 +BoundedSupportKind @ advanced.ts#L47 +BoundedSupportRuleFields @ advanced.ts#L48 +BoundedSupportSurface @ advanced.ts#L49 +CausalIndexFamily @ advanced.ts#L50 +CausalIndexPlanFields @ advanced.ts#L51 +CausalIndexPlanPosture @ advanced.ts#L52 +ObserverConfig @ advanced.ts#L53 +ObserverPlanFields @ advanced.ts#L54 +ObserverReadingEnvelopeBudget @ advanced.ts#L55 +ObserverReadingEnvelopeFields @ advanced.ts#L56 +OpticAperturePostureValue @ advanced.ts#L57 +OpticBasisPostureValue @ advanced.ts#L58 +OpticContextValue @ advanced.ts#L59 +OpticCoordinatePostureValue @ advanced.ts#L60 +OpticFields @ advanced.ts#L61 +OpticPostureFields @ advanced.ts#L62 +OpticSupportRuleValue @ advanced.ts#L63 +SupportFragmentMaterializationPosture @ advanced.ts#L64 +SupportFragmentPlanFields @ advanced.ts#L65 +WarpWorldlineCoordinateFrontierEntry @ advanced.ts#L66 +ZKWormholeEdgeFields @ advanced.ts#L67 +ZKWormholeVerificationResult @ advanced.ts#L68 +``` + +## Diagnostics export surface + +Operator, inspection, comparison, and replay tools. + +### Value exports + +Source: `diagnostics.ts`. Count: 18. + +```text +BisectService @ diagnostics.ts#L9 +CommitDagTraversalService @ diagnostics.ts#L10 +ContentAttachmentProjection @ diagnostics.ts#L11 +exportCoordinateComparisonFact @ diagnostics.ts#L12 +exportCoordinateTransferPlanFact @ diagnostics.ts#L13 +GraphDiff @ diagnostics.ts#L14 +GraphOpAlgebraProjection @ diagnostics.ts#L15 +nodeIdInVisibleStateScope @ diagnostics.ts#L41 +normalizeVisibleStateScope @ diagnostics.ts#L40 +QueryBuilder @ diagnostics.ts#L16 +scopeMaterializedState @ diagnostics.ts#L42 +TtdMergeBranch @ diagnostics.ts#L17 +TtdMergeFootprint @ diagnostics.ts#L18 +TtdMergeInspection @ diagnostics.ts#L19 +TtdMergeInspector @ diagnostics.ts#L20 +TtdMergeLoweringWitness @ diagnostics.ts#L21 +TtdMergeObstructionWitness @ diagnostics.ts#L22 +TtdMergePolicyRequirement @ diagnostics.ts#L23 +``` + +### Type exports + +Source: `diagnostics.ts`. Count: 14. + +```text +GraphDiffFields @ diagnostics.ts#L26 +GraphDiffOptions @ diagnostics.ts#L27 +TtdMergeBranchFields @ diagnostics.ts#L28 +TtdMergeFootprintFields @ diagnostics.ts#L29 +TtdMergeInspectionDomain @ diagnostics.ts#L30 +TtdMergeInspectionFields @ diagnostics.ts#L31 +TtdMergeLoweringSurface @ diagnostics.ts#L32 +TtdMergeLoweringWitnessFields @ diagnostics.ts#L33 +TtdMergeObjectBranchInput @ diagnostics.ts#L34 +TtdMergeObjectInspectionInput @ diagnostics.ts#L35 +TtdMergeObstructionWitnessFields @ diagnostics.ts#L36 +TtdMergePolicyRequirementFields @ diagnostics.ts#L37 +VisibleStateScope @ diagnostics.ts#L45 +VisibleStateScopePrefixFilter @ diagnostics.ts#L46 +``` + +## Legacy export surface + +Deprecated compatibility-only imports for migration paydown. + +### Export modules + +| Module | Kind | Source | +| --- | --- | --- | +| `./src/domain/graph/publicGraphSubstrate.ts` | export * | `legacy.ts#L220` | +| `./src/domain/memory/index.ts` | export * | `legacy.ts#L221` | +| `./src/continuumExports.ts` | export * | `legacy.ts#L222` | + +### Value exports + +Source: `legacy.ts`. Count: 161. + +```text +AlfredOperationPolicyAdapter @ legacy.ts#L227 +ApertureOpeningProof @ legacy.ts#L406 +AuditError @ legacy.ts#L231 +BisectService @ legacy.ts#L272 +BitmapIndexBuilder @ legacy.ts#L266 +BitmapIndexReader @ legacy.ts#L267 +BlobStoragePort @ legacy.ts#L286 +BoundedSupportRule @ legacy.ts#L329 +BTR @ legacy.ts#L395 +buildWarpStateIndex @ legacy.ts#L362 +BunHttpAdapter @ legacy.ts#L296 +canonicalEmissionJson @ legacy.ts#L415 +canonicalObservationJson @ legacy.ts#L417 +CasContentEncryptionPolicy @ legacy.ts#L225 +CausalIndexPlan @ legacy.ts#L330 +checkAborted @ legacy.ts#L303 +ChunkEffectSink @ legacy.ts#L426 +CommitDagTraversalService @ legacy.ts#L271 +compareVisibleState @ legacy.ts#L366 +composeWormholes @ legacy.ts#L402 +computeStateHash @ legacy.ts#L363 +computeTranslationCost @ legacy.ts#L343 +ConsoleEffectSink @ legacy.ts#L425 +ConsoleLogger @ legacy.ts#L279 +CONTENT_PROPERTY_KEY @ legacy.ts#L358 +ContentAttachmentProjection @ legacy.ts#L428 +ContinuumArtifactAuthorityError @ legacy.ts#L232 +CoordinateSelector @ legacy.ts#L327 +createBlobValue @ legacy.ts#L352 +createBTR @ legacy.ts#L396 +createDeliveryObservation @ legacy.ts#L416 +createEdgeAdd @ legacy.ts#L348 +createEdgeTombstone @ legacy.ts#L349 +createEffectEmission @ legacy.ts#L414 +createExternalizationPolicy @ legacy.ts#L418 +createInlineValue @ legacy.ts#L351 +createNodeAdd @ legacy.ts#L346 +createNodeTombstone @ legacy.ts#L347 +createPropSet @ legacy.ts#L350 +createStateReader @ legacy.ts#L365 +createTickReceipt @ legacy.ts#L386 +createTimeoutSignal @ legacy.ts#L304 +createV18BoundedMemoryCapabilityReport @ legacy.ts#L383 +createWormhole @ legacy.ts#L401 +CryptoPort @ legacy.ts#L288 +decodeEdgePropKey @ legacy.ts#L356 +DELIVERY_MODES @ legacy.ts#L419 +DELIVERY_OUTCOMES @ legacy.ts#L420 +DenoHttpAdapter @ legacy.ts#L297 +deserializeWormhole @ legacy.ts#L405 +EffectPipeline @ legacy.ts#L413 +EffectSinkPort @ legacy.ts#L411 +encodeEdgePropKey @ legacy.ts#L355 +EncryptionError @ legacy.ts#L233 +exportCoordinateComparisonFact @ legacy.ts#L381 +exportCoordinateTransferPlanFact @ legacy.ts#L382 +ForkError @ legacy.ts#L234 +GitGraphAdapter @ legacy.ts#L262 +GraphDiff @ legacy.ts#L367 +GraphNode @ legacy.ts#L264 +GraphOpAlgebraProjection @ legacy.ts#L265 +GraphPersistencePort @ legacy.ts#L273 +HealthCheckService @ legacy.ts#L269 +HealthStatus @ legacy.ts#L270 +HttpServerPort @ legacy.ts#L289 +ImmutableBytes @ legacy.ts#L375 +IndexError @ legacy.ts#L235 +IndexRebuildService @ legacy.ts#L268 +IndexStoragePort @ legacy.ts#L274 +InMemoryBlobStorageAdapter @ legacy.ts#L287 +InMemoryGraphAdapter @ legacy.ts#L263 +INSPECT_LENS @ legacy.ts#L423 +isEdgePropKey @ legacy.ts#L357 +LIVE_LENS @ legacy.ts#L421 +LiveSelector @ legacy.ts#L326 +LoggerPort @ legacy.ts#L277 +LogLevel @ legacy.ts#L280 +MemoryBudgetError @ legacy.ts#L236 +MultiplexSink @ legacy.ts#L412 +NodeCryptoAdapter @ legacy.ts#L292 +NoOpEffectSink @ legacy.ts#L424 +NoOpLogger @ legacy.ts#L278 +NoopOperationPolicyAdapter @ legacy.ts#L228 +normalizeVisibleStateScope @ legacy.ts#L379 +Observer @ legacy.ts#L333 +ObserverAccumulation @ legacy.ts#L334 +ObserverBasis @ legacy.ts#L335 +ObserverEmission @ legacy.ts#L336 +ObserverPlan @ legacy.ts#L337 +ObserverReadingEnvelope @ legacy.ts#L338 +openAperture @ legacy.ts#L408 +openWarpGraph @ legacy.ts#L308 +openWarpWorldline @ legacy.ts#L311 +OperationAbortedError @ legacy.ts#L237 +OperationPolicyExhaustedError @ legacy.ts#L229 +OperationPolicyPort @ legacy.ts#L223 +OperationPolicyTimeoutError @ legacy.ts#L229 +Optic @ legacy.ts#L315 +OpticAperturePosture @ legacy.ts#L316 +OpticBasisPosture @ legacy.ts#L317 +OpticCoordinatePosture @ legacy.ts#L318 +OpticSupportRule @ legacy.ts#L319 +PatchBuilder @ legacy.ts#L339 +PatchError @ legacy.ts#L238 +PatchSession @ legacy.ts#L340 +ProjectionHandle @ legacy.ts#L320 +projectState @ legacy.ts#L364 +ProvenanceIndex @ legacy.ts#L342 +ProvenancePayload @ legacy.ts#L392 +QueryBuilder @ legacy.ts#L332 +QueryError @ legacy.ts#L239 +RejectedApertureOpening @ legacy.ts#L406 +RejectedZKWormhole @ legacy.ts#L406 +REPLAY_LENS @ legacy.ts#L422 +replayBTR @ legacy.ts#L398 +replayWormhole @ legacy.ts#L403 +SchemaUnsupportedError @ legacy.ts#L240 +scopeMaterializedState @ legacy.ts#L380 +SeekCachePort @ legacy.ts#L283 +serializeWormhole @ legacy.ts#L404 +ShardCorruptionError @ legacy.ts#L241 +ShardLoadError @ legacy.ts#L242 +ShardValidationError @ legacy.ts#L243 +SnapshotORSet @ legacy.ts#L376 +SnapshotVersionVector @ legacy.ts#L377 +SnapshotWarpState @ legacy.ts#L378 +StorageError @ legacy.ts#L244 +StrandError @ legacy.ts#L245 +StrandSelector @ legacy.ts#L328 +SupportFragmentPlan @ legacy.ts#L331 +SyncError @ legacy.ts#L246 +SyncSecret @ legacy.ts#L427 +TICK_RECEIPT_OP_TYPES @ legacy.ts#L388 +TICK_RECEIPT_RESULT_TYPES @ legacy.ts#L389 +tickReceiptCanonicalJson @ legacy.ts#L387 +TraversalError @ legacy.ts#L247 +TtdMergeBranch @ legacy.ts#L368 +TtdMergeFootprint @ legacy.ts#L369 +TtdMergeInspection @ legacy.ts#L370 +TtdMergeInspector @ legacy.ts#L371 +TtdMergeLoweringWitness @ legacy.ts#L372 +TtdMergeObstructionWitness @ legacy.ts#L373 +TtdMergePolicyRequirement @ legacy.ts#L374 +VerifiedApertureOpening @ legacy.ts#L407 +VerifiedZKWormhole @ legacy.ts#L407 +verifyBTR @ legacy.ts#L397 +verifyZKWormhole @ legacy.ts#L408 +WarpApp @ legacy.ts#L323 +WarpCore @ legacy.ts#L324 +WarpOpenOptions @ legacy.ts#L307 +WarpStateIndexBuilder @ legacy.ts#L361 +WarpWorldline @ legacy.ts#L312 +WarpWorldlineCoordinate @ legacy.ts#L313 +WarpWorldlineOpticBasis @ legacy.ts#L314 +WebCryptoAdapter @ legacy.ts#L293 +WorldlineSelector @ legacy.ts#L325 +WormholeError @ legacy.ts#L248 +Writer @ legacy.ts#L341 +WriterError @ legacy.ts#L300 +ZKWormholeEdge @ legacy.ts#L407 +ZKWormholeProofVerifierPort @ legacy.ts#L408 ``` -## Root API type exports +### Type exports -Source: `index.ts`. Count: 8. +Source: `legacy.ts`. Count: 51. ```text -CasContentEncryptionDiagnostics @ index.ts#L32 -CasContentEncryptionScheme @ index.ts#L33 -CasResolvedVaultKeyOptions @ index.ts#L34 -OpenWarpOptions @ index.ts#L21 -OperationPolicyExecuteOptions @ index.ts#L27 -OperationRetryDecision @ index.ts#L28 -SyncRateLimitConfig @ index.ts#L74 -WarpStorage @ index.ts#L21 +Aperture @ legacy.ts#L432 +ApertureOpeningProofFields @ legacy.ts#L461 +ApertureOpeningVerificationResult @ legacy.ts#L461 +BoundedSupportDirection @ legacy.ts#L447 +BoundedSupportKind @ legacy.ts#L448 +BoundedSupportRuleFields @ legacy.ts#L449 +BoundedSupportSurface @ legacy.ts#L450 +CasContentEncryptionDiagnostics @ legacy.ts#L226 +CasContentEncryptionScheme @ legacy.ts#L226 +CasResolvedVaultKeyOptions @ legacy.ts#L226 +CasVaultResolutionWitness @ legacy.ts#L226 +CausalIndexFamily @ legacy.ts#L451 +CausalIndexPlanFields @ legacy.ts#L452 +CausalIndexPlanPosture @ legacy.ts#L453 +GraphDiffFields @ legacy.ts#L457 +GraphDiffOptions @ legacy.ts#L456 +ObserverConfig @ legacy.ts#L433 +ObserverPlanFields @ legacy.ts#L458 +ObserverReadingEnvelopeBudget @ legacy.ts#L459 +ObserverReadingEnvelopeFields @ legacy.ts#L460 +OperationPolicyExecuteOptions @ legacy.ts#L224 +OperationRetryDecision @ legacy.ts#L224 +OperationRetryObserver @ legacy.ts#L224 +OpticAperturePostureValue @ legacy.ts#L440 +OpticBasisPostureValue @ legacy.ts#L441 +OpticContextValue @ legacy.ts#L442 +OpticCoordinatePostureValue @ legacy.ts#L443 +OpticFields @ legacy.ts#L444 +OpticPostureFields @ legacy.ts#L445 +OpticSupportRuleValue @ legacy.ts#L446 +PropValue @ legacy.ts#L434 +SnapshotPropValue @ legacy.ts#L435 +SupportFragmentMaterializationPosture @ legacy.ts#L454 +SupportFragmentPlanFields @ legacy.ts#L455 +SyncRateLimitConfig @ legacy.ts#L436 +TtdMergeBranchFields @ legacy.ts#L464 +TtdMergeFootprintFields @ legacy.ts#L465 +TtdMergeInspectionDomain @ legacy.ts#L466 +TtdMergeInspectionFields @ legacy.ts#L467 +TtdMergeLoweringSurface @ legacy.ts#L468 +TtdMergeLoweringWitnessFields @ legacy.ts#L469 +TtdMergeObjectBranchInput @ legacy.ts#L470 +TtdMergeObjectInspectionInput @ legacy.ts#L471 +TtdMergeObstructionWitnessFields @ legacy.ts#L472 +TtdMergePolicyRequirementFields @ legacy.ts#L473 +WarpKernelPort @ legacy.ts#L437 +WarpWorldlineCoordinateFrontierEntry @ legacy.ts#L463 +WarpWorldlineOpenOptions @ legacy.ts#L438 +WarpWorldlinePatchBuild @ legacy.ts#L439 +ZKWormholeEdgeFields @ legacy.ts#L462 +ZKWormholeVerificationResult @ legacy.ts#L462 ``` ## CLI command registry diff --git a/index.ts b/index.ts index 35e27eba3..3f4b89df1 100644 --- a/index.ts +++ b/index.ts @@ -16,9 +16,41 @@ import { installDefaultRuntimeHostNodePorts } from './src/application/RuntimeHos installDefaultRuntimeHostNodePorts(); export { openWarp } from './src/domain/api/openWarp.ts'; +export { default as DraftTimeline } from './src/domain/api/DraftTimeline.ts'; export { default as Warp } from './src/domain/api/Warp.ts'; export { default as Timeline } from './src/domain/api/Timeline.ts'; +export { intent } from './src/domain/api/IntentBuilders.ts'; +export { default as Intent } from './src/domain/api/Intent.ts'; +export { default as JoinReceipt } from './src/domain/api/JoinReceipt.ts'; +export { default as JoinResult } from './src/domain/api/JoinResult.ts'; +export { reading } from './src/domain/api/ReadingBuilders.ts'; +export { default as Reading } from './src/domain/api/Reading.ts'; +export { default as ReadingResult } from './src/domain/api/ReadingResult.ts'; +export { default as ReadReceipt } from './src/domain/api/ReadReceipt.ts'; +export { default as WriteReceipt } from './src/domain/api/WriteReceipt.ts'; export type { OpenWarpOptions, WarpStorage } from './src/domain/api/openWarp.ts'; +export type { + EdgeIntentFields, + EdgePropertyIntentFields, + IntentDescriptor, + IntentKind, + NodeIntentFields, + PropertyIntentFields, +} from './src/domain/api/Intent.ts'; +export type { IntentBuilders } from './src/domain/api/IntentBuilders.ts'; +export type { JoinMode, JoinReceiptOptions, JoinReceiptOutcome } from './src/domain/api/JoinReceipt.ts'; +export type { JoinResultOptions } from './src/domain/api/JoinResult.ts'; +export type { JoinOptions, JoinPolicy } from './src/domain/api/Timeline.ts'; +export type { + NodeReadingFields, + PropertyReadingFields, + ReadingDescriptor, + ReadingKind, +} from './src/domain/api/Reading.ts'; +export type { ReadingBuilders } from './src/domain/api/ReadingBuilders.ts'; +export type { ReadingResultOptions, ReadingValue } from './src/domain/api/ReadingResult.ts'; +export type { ReadReceiptOptions, ReadReceiptOutcome } from './src/domain/api/ReadReceipt.ts'; +export type { ReceiptOutcome, WriteReceiptOptions } from './src/domain/api/WriteReceipt.ts'; export * from './src/domain/memory/index.ts'; diff --git a/package.json b/package.json index b33457c1c..f929b8c01 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "lint:md": "markdownlint \"**/*.md\" --ignore node_modules --ignore \"**/node_modules/**\"", "lint:md:code": "node scripts/lint-markdown-code-samples.ts", "lint:docs-topology": "bash scripts/check-docs-topology.sh && npm run lint:source-backed-reference", - "lint:links": "lychee --config .lychee.toml '**/*.md'", + "lint:links": "lychee --config .lychee.toml --include-fragments '**/*.md'", "lint:semgrep": "node scripts/lint-semgrep-with-quarantines.ts", "lint:sludge": "bash scripts/check-anti-sludge.sh", "lint:cas-invariants": "bash scripts/check-git-cas-invariants.sh", diff --git a/scripts/check-source-backed-reference.ts b/scripts/check-source-backed-reference.ts index 5b378e1d2..9c934747b 100644 --- a/scripts/check-source-backed-reference.ts +++ b/scripts/check-source-backed-reference.ts @@ -1,33 +1,17 @@ import { readFileSync, writeFileSync } from 'node:fs'; import process from 'node:process'; - const OUTPUT_PATH = 'docs/topics/reference.md'; - class SourceText { readonly path: string; readonly lines: readonly string[]; - constructor(path: string) { - this.path = path; - this.lines = readFileSync(path, 'utf8').split('\n'); - } - + constructor(path: string) { this.path = path; this.lines = readFileSync(path, 'utf8').split('\n'); } line(index: number): string { return this.lines[index] ?? ''; } - ref(index: number): string { return `${this.path}#L${index + 1}`; } } -class InventoryItem { - readonly name: string; - readonly detail: string; - readonly source: string; - - constructor(name: string, detail: string, source: string) { - this.name = name; - this.detail = detail; - this.source = source; - } -} +type InventoryItem = { readonly name: string; readonly detail: string; readonly source: string }; +function inventoryItem(name: string, detail: string, source: string): InventoryItem { return Object.freeze({ name, detail, source }); } function captureObjectEntries(source: SourceText, field: string): readonly InventoryItem[] { const items: InventoryItem[] = []; @@ -54,7 +38,7 @@ function captureObjectEntries(source: SourceText, field: string): readonly Inven const match = /^\s+"([^"]+)":\s+"([^"]+)"/.exec(line); if (match) { - items.push(new InventoryItem(match[1] ?? '', match[2] ?? '', source.ref(index))); + items.push(inventoryItem(match[1] ?? '', match[2] ?? '', source.ref(index))); } } @@ -81,7 +65,7 @@ function captureExportEntries(source: SourceText, field: string): readonly Inven if (depth === 1) { const stringMatch = /^\s+"([^"]+)":\s+"([^"]+)"/.exec(line); if (stringMatch) { - items.push(new InventoryItem(stringMatch[1] ?? '', stringMatch[2] ?? '', source.ref(index))); + items.push(inventoryItem(stringMatch[1] ?? '', stringMatch[2] ?? '', source.ref(index))); } const objectMatch = /^\s+"([^"]+)":\s+\{$/.exec(line); @@ -99,7 +83,7 @@ function captureExportEntries(source: SourceText, field: string): readonly Inven nestedDepth -= (nestedLine.match(/}/g) ?? []).length; nestedIndex += 1; } - items.push(new InventoryItem(objectMatch[1] ?? '', details.join('; '), source.ref(index))); + items.push(inventoryItem(objectMatch[1] ?? '', details.join('; '), source.ref(index))); index = nestedIndex - 1; continue; } @@ -141,7 +125,7 @@ function collectLineNames(line: string): readonly string[] { .sort((left, right) => left.localeCompare(right)); } -function captureRootExports(indexSource: SourceText, kind: 'values' | 'types'): readonly InventoryItem[] { +function captureExports(indexSource: SourceText, kind: 'values' | 'types'): readonly InventoryItem[] { const items: InventoryItem[] = []; const prefix = kind === 'types' ? 'export type {' : 'export {'; @@ -155,7 +139,7 @@ function captureRootExports(indexSource: SourceText, kind: 'values' | 'types'): while (exportIndex < indexSource.lines.length) { const exportLine = indexSource.line(exportIndex); for (const name of collectLineNames(exportLine)) { - items.push(new InventoryItem(name, kind, indexSource.ref(exportIndex))); + items.push(inventoryItem(name, kind, indexSource.ref(exportIndex))); } if (exportLine.includes('};') || exportLine.includes("} from ")) { break; @@ -174,7 +158,7 @@ function captureReExportModules(indexSource: SourceText): readonly InventoryItem for (let index = 0; index < indexSource.lines.length; index += 1) { const match = /^export \* from '([^']+)';$/.exec(indexSource.line(index)); if (match) { - items.push(new InventoryItem(match[1] ?? '', 'export *', indexSource.ref(index))); + items.push(inventoryItem(match[1] ?? '', 'export *', indexSource.ref(index))); } } return items; @@ -185,7 +169,7 @@ function captureCommands(registrySource: SourceText): readonly InventoryItem[] { for (let index = 0; index < registrySource.lines.length; index += 1) { const match = /^\s+\['([^']+)',\s*([A-Za-z0-9_]+)\],$/.exec(registrySource.line(index)); if (match) { - items.push(new InventoryItem(match[1] ?? '', match[2] ?? '', registrySource.ref(index))); + items.push(inventoryItem(match[1] ?? '', match[2] ?? '', registrySource.ref(index))); } } return items; @@ -196,7 +180,7 @@ function captureErrorClasses(errorSource: SourceText): readonly InventoryItem[] for (let index = 0; index < errorSource.lines.length; index += 1) { const match = /^export \{ default as ([A-Za-z0-9_]+) \} from '([^']+)';$/.exec(errorSource.line(index)); if (match) { - items.push(new InventoryItem(match[1] ?? '', match[2] ?? '', errorSource.ref(index))); + items.push(inventoryItem(match[1] ?? '', match[2] ?? '', errorSource.ref(index))); } } return items; @@ -221,20 +205,44 @@ function requireLineRef(source: SourceText, needle: string): string { throw new Error(`${needle} not found in ${source.path}`); } +function exportSurface(title: string, source: SourceText, contract: string): readonly string[] { + const modules = captureReExportModules(source); + const valueExports = captureExports(source, 'values'); + const typeExports = captureExports(source, 'types'); + return [ + `## ${title}`, + '', + contract, + '', + ...(modules.length === 0 ? [] : ['### Export modules', '', table(['Module', 'Kind', 'Source'], modules.map((item) => [`\`${item.name}\``, item.detail, `\`${item.source}\``])), '']), + '### Value exports', + '', + `Source: \`${source.path}\`. Count: ${valueExports.length}.`, + '', + codeList(valueExports), + '', + '### Type exports', + '', + `Source: \`${source.path}\`. Count: ${typeExports.length}.`, + '', + codeList(typeExports), + ]; +} + function generate(): string { const packageSource = new SourceText('package.json'); const jsrSource = new SourceText('jsr.json'); - const indexSource = new SourceText('index.ts'); const registrySource = new SourceText('bin/cli/commands/registry.ts'); const cliSource = new SourceText('bin/warp-graph.ts'); const errorSource = new SourceText('src/domain/errors/index.ts'); - + const rootSource = new SourceText('index.ts'); + const storageSource = new SourceText('storage.ts'); + const advancedSource = new SourceText('advanced.ts'); + const diagnosticsSource = new SourceText('diagnostics.ts'); + const legacySource = new SourceText('legacy.ts'); const packageBins = captureObjectEntries(packageSource, 'bin'); const packageExports = captureExportEntries(packageSource, 'exports').filter((item) => item.name.startsWith('.')); const jsrExports = captureExportEntries(jsrSource, 'exports').filter((item) => item.name.startsWith('.')); - const reExports = captureReExportModules(indexSource); - const valueExports = captureRootExports(indexSource, 'values'); - const typeExports = captureRootExports(indexSource, 'types'); const commands = captureCommands(registrySource); const errors = captureErrorClasses(errorSource); @@ -253,21 +261,15 @@ function generate(): string { ...jsrExports.map((item) => ['JSR export', `\`${item.name}\``, `\`${item.detail}\``, `\`${item.source}\``]), ]), '', - '## Root API export modules', - '', - table(['Module', 'Kind', 'Source'], reExports.map((item) => [`\`${item.name}\``, item.detail, `\`${item.source}\``])), - '', - '## Root API value exports', + ...exportSurface('Root API export surface', rootSource, 'First-use product API: `openWarp`, `intent`, `reading`, timelines, and receipts.'), '', - `Source: \`index.ts\`. Count: ${valueExports.length}.`, + ...exportSurface('Storage export surface', storageSource, 'Supported persistence and crypto adapters for first-use applications.'), '', - codeList(valueExports), - '', - '## Root API type exports', + ...exportSurface('Advanced export surface', advancedSource, 'Formal WARP and Continuum concepts for expert use; not first-use root API.'), '', - `Source: \`index.ts\`. Count: ${typeExports.length}.`, + ...exportSurface('Diagnostics export surface', diagnosticsSource, 'Operator, inspection, comparison, and replay tools.'), '', - codeList(typeExports), + ...exportSurface('Legacy export surface', legacySource, 'Deprecated compatibility-only imports for migration paydown.'), '', '## CLI command registry', '', diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push index 4994ba14c..9fdcf763b 100755 --- a/scripts/hooks/pre-push +++ b/scripts/hooks/pre-push @@ -54,7 +54,7 @@ echo "════════════════════════ # ── Link check (optional) ────────────────────────────────────────────────── if command_exists "$LINKCHECK_LAUNCHER" "$LINKCHECK_BIN"; then echo "[Gate 0] Link check..." - run_tool "$LINKCHECK_LAUNCHER" "$LINKCHECK_BIN" --config .lychee.toml '**/*.md' + run_tool "$LINKCHECK_LAUNCHER" "$LINKCHECK_BIN" --config .lychee.toml --include-fragments '**/*.md' else echo "[Gate 0] Link check skipped (lychee not installed)" fi diff --git a/src/domain/WarpWorldline.ts b/src/domain/WarpWorldline.ts index 1b507126b..0e8d90f7c 100644 --- a/src/domain/WarpWorldline.ts +++ b/src/domain/WarpWorldline.ts @@ -30,9 +30,17 @@ export type WarpWorldlinePatchBuild = ( ) => void | Promise; type CommitPatch = (build: WarpWorldlinePatchBuild) => Promise; +type CreateDraft = (name: string) => Promise; type WorldlineOptions = Parameters[0]; type CreateWorldline = (options?: WorldlineOptions) => ProjectionHandle; +type PatchDraft = (name: string, build: WarpWorldlinePatchBuild) => Promise; +type PreviewDraftJoin = (name: string) => Promise; +type RuntimeGraph = Awaited>; type PrepareOpticBasis = () => Promise; +type DraftWorldlineOptions = Pick< + WarpWorldlineConstructionOptions, + 'createDraft' | 'patchDraft' | 'previewDraftJoin' +>; type GetFrontier = () => Promise>; type ReadOpticBasis = () => WarpWorldlineOpticBasis | null; type ReadCapabilities = typeof createBoundedMemoryCapabilityReport; @@ -42,7 +50,10 @@ type WarpWorldlineConstructionOptions = { readonly worldlineName: string; readonly writerId: string; readonly commitPatch: CommitPatch; + readonly createDraft?: CreateDraft; readonly createWorldline: CreateWorldline; + readonly patchDraft?: PatchDraft; + readonly previewDraftJoin?: PreviewDraftJoin; readonly prepareOpticBasis?: PrepareOpticBasis; readonly getFrontier?: GetFrontier; readonly readOpticBasis?: ReadOpticBasis; @@ -54,7 +65,10 @@ export default class WarpWorldline { readonly worldlineName: string; readonly writerId: string; private readonly _commitPatch: CommitPatch; + private readonly _createDraft: CreateDraft | null; private readonly _createWorldline: CreateWorldline; + private readonly _patchDraft: PatchDraft | null; + private readonly _previewDraftJoin: PreviewDraftJoin | null; private readonly _prepareOpticBasis: PrepareOpticBasis | null; private readonly _getFrontier: GetFrontier | null; private readonly _readOpticBasis: ReadOpticBasis | null; @@ -67,10 +81,13 @@ export default class WarpWorldline { this.worldlineName = options.worldlineName; this.writerId = options.writerId; this._commitPatch = options.commitPatch; + this._createDraft = optionalPort(options.createDraft); this._createWorldline = options.createWorldline; - this._prepareOpticBasis = options.prepareOpticBasis ?? null; - this._getFrontier = options.getFrontier ?? null; - this._readOpticBasis = options.readOpticBasis ?? null; + this._patchDraft = optionalPort(options.patchDraft); + this._previewDraftJoin = optionalPort(options.previewDraftJoin); + this._prepareOpticBasis = optionalPort(options.prepareOpticBasis); + this._getFrontier = optionalPort(options.getFrontier); + this._readOpticBasis = optionalPort(options.readOpticBasis); this._readCapabilities = options.readCapabilities ?? createBoundedMemoryCapabilityReport; this._admitIntent = options.admitIntent; Object.freeze(this); @@ -84,6 +101,27 @@ export default class WarpWorldline { return await this._admitIntent(descriptor); } + async createDraft(name: string): Promise { + if (this._createDraft === null) { + throw new WarpError('WarpWorldline was not opened with draft support', 'E_WARP_WORLDLINE_DRAFT_UNAVAILABLE'); + } + await this._createDraft(name); + } + + async patchDraft(name: string, build: WarpWorldlinePatchBuild): Promise { + if (this._patchDraft === null) { + throw new WarpError('WarpWorldline was not opened with draft support', 'E_WARP_WORLDLINE_DRAFT_UNAVAILABLE'); + } + return await this._patchDraft(name, build); + } + + async previewDraftJoin(name: string): Promise { + if (this._previewDraftJoin === null) { + throw new WarpError('WarpWorldline was not opened with draft support', 'E_WARP_WORLDLINE_DRAFT_UNAVAILABLE'); + } + return await this._previewDraftJoin(name); + } + live(): ProjectionHandle { return this._createWorldline(); } @@ -155,6 +193,10 @@ export default class WarpWorldline { } } +function optionalPort(port: TPort | undefined): TPort | null { + return port ?? null; +} + /** * Opens a deprecated worldline compatibility handle. * @@ -171,12 +213,17 @@ export async function openWarpWorldline( ...graphOptions, graphName: worldlineName, }); + return createWarpWorldline(worldlineName, graph); +} + +function createWarpWorldline(worldlineName: string, graph: RuntimeGraph): WarpWorldline { let preparedOpticBasis: WarpWorldlineOpticBasis | null = null; return new WarpWorldline({ worldlineName, writerId: graph.writerId, commitPatch: async (build) => await graph.patch(build), + ...draftWorldlineOptions(graph), createWorldline: (worldlineOptions) => graph.worldline(worldlineOptions), prepareOpticBasis: async () => { const basis = await new CheckpointTailBasisVerifier({ source: graph }).verify(); @@ -192,6 +239,22 @@ export async function openWarpWorldline( }); } +function draftWorldlineOptions(graph: RuntimeGraph): DraftWorldlineOptions { + return { + createDraft: async (name) => { + await graph.createStrand({ + strandId: name, + owner: graph.writerId, + }); + }, + patchDraft: async (name, build) => await graph.patchStrand(name, build), + previewDraftJoin: async (name) => { + await graph.materializeStrand(name, { receipts: true }); + return (await graph.getStrandPatches(name)).map((entry) => entry.sha); + }, + }; +} + function assertNonEmpty(value: string | null | undefined, field: string): void { if (typeof value !== 'string' || value.trim().length === 0) { throw new WarpError( diff --git a/src/domain/api/DraftTimeline.ts b/src/domain/api/DraftTimeline.ts new file mode 100644 index 000000000..4e46d4e95 --- /dev/null +++ b/src/domain/api/DraftTimeline.ts @@ -0,0 +1,75 @@ +import WarpError from '../errors/WarpError.ts'; +import { assertTimelineNameIdentity, assertWriterIdentity } from './assertIdentity.ts'; +import Intent from './Intent.ts'; +import type WriteReceipt from './WriteReceipt.ts'; + +type DraftTimelineConstructionOptions = { + readonly name: string; + readonly timeline: string; + readonly writer: string; + readonly writeDraft?: WriteDraft; +}; + +type WriteDraft = (intent: Intent) => Promise; + +export default class DraftTimeline { + readonly #name: string; + readonly #timeline: string; + readonly #writeDraft: WriteDraft | null; + readonly #writer: string; + + constructor(options: DraftTimelineConstructionOptions) { + assertDraftTimelineConstructionOptions(options); + assertTimelineNameIdentity(options.name, 'name', { + message: 'DraftTimeline requires non-empty identity fields', + code: 'E_DRAFT_TIMELINE_IDENTITY', + }); + assertTimelineNameIdentity(options.timeline, 'timeline', { + message: 'DraftTimeline requires non-empty identity fields', + code: 'E_DRAFT_TIMELINE_IDENTITY', + }); + assertWriterIdentity(options.writer, 'writer', { + message: 'DraftTimeline requires non-empty identity fields', + code: 'E_DRAFT_TIMELINE_IDENTITY', + }); + this.#name = options.name; + this.#timeline = options.timeline; + this.#writer = options.writer; + this.#writeDraft = options.writeDraft ?? null; + Object.freeze(this); + } + + get name(): string { + return this.#name; + } + + get timeline(): string { + return this.#timeline; + } + + get writer(): string { + return this.#writer; + } + + async write(intent: Intent): Promise { + if (!(intent instanceof Intent)) { + throw new WarpError('DraftTimeline.write requires an Intent', 'E_DRAFT_WRITE_INTENT'); + } + if (this.#writeDraft === null) { + throw new WarpError('DraftTimeline was not opened by Timeline.draft', 'E_DRAFT_RUNTIME_UNAVAILABLE'); + } + return await this.#writeDraft(intent); + } +} + +function assertDraftTimelineConstructionOptions(options: DraftTimelineConstructionOptions): void { + if (options === null || options === undefined) { + throw new WarpError( + 'DraftTimeline requires construction options', + 'E_DRAFT_CONSTRUCTION_OPTIONS', + ); + } + if (options.writeDraft !== undefined && typeof options.writeDraft !== 'function') { + throw new WarpError('DraftTimeline requires a writeDraft function when provided', 'E_DRAFT_WRITER'); + } +} diff --git a/src/domain/api/DraftTimelineRuntime.ts b/src/domain/api/DraftTimelineRuntime.ts new file mode 100644 index 000000000..16ec03694 --- /dev/null +++ b/src/domain/api/DraftTimelineRuntime.ts @@ -0,0 +1,251 @@ +import type WarpWorldline from '../WarpWorldline.ts'; +import WarpError from '../errors/WarpError.ts'; +import DraftTimeline from './DraftTimeline.ts'; +import type Intent from './Intent.ts'; +import { applyIntentToPatch } from './IntentRuntime.ts'; +import JoinReceipt from './JoinReceipt.ts'; +import JoinResult from './JoinResult.ts'; +import type { JoinOptions } from './Timeline.ts'; +import WriteReceipt from './WriteReceipt.ts'; + +type DraftTimelineState = { + readonly runtime: WarpWorldline; + readonly draftPatchShas: string[]; + readonly intents: Intent[]; + readonly joinPatchShas: string[]; + joinFailed: boolean; + joining: boolean; + joined: boolean; +}; + +type DraftWriteFields = { + readonly runtime: WarpWorldline; + readonly timelineName: string; + readonly draftName: string; + readonly state: DraftTimelineState; + readonly intent: Intent; +}; + +type JoinResultFields = { + readonly runtime: WarpWorldline; + readonly draft: DraftTimeline; + readonly mode: 'preview' | 'join'; + readonly outcome: 'accepted' | 'rejected'; + readonly patchShas: readonly string[]; + readonly reason?: string; +}; + +type RejectedJoinFields = { + readonly runtime: WarpWorldline; + readonly draft: DraftTimeline; + readonly reason: string; + readonly patchShas?: readonly string[]; +}; + +type JoinCompletionFields = { + readonly runtime: WarpWorldline; + readonly draft: DraftTimeline; + readonly state: DraftTimelineState; + readonly patchShas: readonly string[]; +}; + +const draftStates = new WeakMap(); + +export async function createDraftTimeline( + runtime: WarpWorldline, + timelineName: string, + draftName: string, +): Promise { + await runtime.createDraft(draftName); + const state = createDraftState(runtime); + const draft = new DraftTimeline({ + name: draftName, + timeline: timelineName, + writer: runtime.writerId, + writeDraft: async (intent) => await writeDraftIntent({ + runtime, + timelineName, + draftName, + state, + intent, + }), + }); + draftStates.set(draft, state); + return draft; +} + +export async function previewDraftJoin( + runtime: WarpWorldline, + draft: DraftTimeline, + options: JoinOptions, +): Promise { + void options; + requireDraftState(runtime, draft); + const patchShas = await runtime.previewDraftJoin(draft.name); + return joinResult({ + runtime, + draft, + mode: 'preview', + outcome: 'accepted', + patchShas, + }); +} + +export async function joinDraftTimeline( + runtime: WarpWorldline, + draft: DraftTimeline, + options: JoinOptions, +): Promise { + void options; + const state = requireDraftState(runtime, draft); + const rejected = rejectedJoinPrecondition(runtime, draft, state); + if (rejected !== null) { + return rejected; + } + + state.joining = true; + const patchShas = await commitDraftIntents(runtime, state); + const failed = rejectedIncompleteJoin({ runtime, draft, state, patchShas }); + if (failed !== null) { + state.joining = false; + return failed; + } + state.joined = true; + state.joining = false; + return acceptedJoin({ runtime, draft, state, patchShas }); +} + +function rejectedJoinPrecondition( + runtime: WarpWorldline, + draft: DraftTimeline, + state: DraftTimelineState, +): JoinResult | null { + if (state.joined) { + return rejectedJoin({ runtime, draft, reason: 'Draft has already joined' }); + } + if (state.joining) { + return rejectedJoin({ runtime, draft, reason: 'Draft join is already in progress' }); + } + if (state.joinFailed) { + return rejectedJoin({ + runtime, + draft, + reason: 'Draft join already has a failed commit attempt', + patchShas: state.joinPatchShas, + }); + } + if (state.intents.length === 0) { + return rejectedJoin({ runtime, draft, reason: 'Draft has no public intents to join' }); + } + return null; +} + +function rejectedIncompleteJoin(fields: JoinCompletionFields): JoinResult | null { + if (fields.patchShas.length === fields.state.intents.length) { + return null; + } + fields.state.joinFailed = true; + return rejectedJoin({ + runtime: fields.runtime, + draft: fields.draft, + reason: 'Draft join failed while committing intents', + patchShas: fields.patchShas, + }); +} + +function acceptedJoin(fields: JoinCompletionFields): JoinResult { + return joinResult({ + runtime: fields.runtime, + draft: fields.draft, + mode: 'join', + outcome: 'accepted', + patchShas: fields.patchShas, + }); +} + +function createDraftState(runtime: WarpWorldline): DraftTimelineState { + return { + runtime, + draftPatchShas: [], + intents: [], + joinPatchShas: [], + joinFailed: false, + joining: false, + joined: false, + }; +} + +async function writeDraftIntent(fields: DraftWriteFields): Promise { + const patchSha = await fields.runtime.patchDraft(fields.draftName, (patch) => { + applyIntentToPatch(fields.intent, patch); + }); + fields.state.draftPatchShas.push(patchSha); + fields.state.intents.push(fields.intent); + return new WriteReceipt({ + timeline: fields.timelineName, + writer: fields.runtime.writerId, + intent: fields.intent, + outcome: 'accepted', + patchSha, + }); +} + +function rejectedJoin(fields: RejectedJoinFields): JoinResult { + return joinResult({ + runtime: fields.runtime, + draft: fields.draft, + mode: 'join', + outcome: 'rejected', + patchShas: fields.patchShas ?? [], + reason: fields.reason, + }); +} + +async function commitDraftIntents( + runtime: WarpWorldline, + state: DraftTimelineState, +): Promise { + for (const intent of state.intents) { + try { + const patchSha = await runtime.commit((patch) => { + applyIntentToPatch(intent, patch); + }); + state.joinPatchShas.push(patchSha); + } catch { + return Object.freeze([...state.joinPatchShas]); + } + } + return Object.freeze([...state.joinPatchShas]); +} + +function requireDraftState(runtime: WarpWorldline, draft: DraftTimeline): DraftTimelineState { + const state = draftStates.get(draft); + if (state === undefined || state.runtime !== runtime) { + throw new WarpError('DraftTimeline was not opened by this Timeline', 'E_DRAFT_RUNTIME_UNAVAILABLE'); + } + return state; +} + +function joinResult(fields: JoinResultFields): JoinResult { + const receipt = fields.reason === undefined + ? new JoinReceipt({ + timeline: fields.runtime.worldlineName, + writer: fields.runtime.writerId, + draft: fields.draft, + mode: fields.mode, + outcome: fields.outcome, + patchShas: fields.patchShas, + }) + : new JoinReceipt({ + timeline: fields.runtime.worldlineName, + writer: fields.runtime.writerId, + draft: fields.draft, + mode: fields.mode, + outcome: fields.outcome, + patchShas: fields.patchShas, + reason: fields.reason, + }); + return new JoinResult({ + receipt, + }); +} diff --git a/src/domain/api/Intent.ts b/src/domain/api/Intent.ts new file mode 100644 index 000000000..31efb219a --- /dev/null +++ b/src/domain/api/Intent.ts @@ -0,0 +1,188 @@ +import WarpError from '../errors/WarpError.ts'; +import { isPropValue, type PropValue } from '../types/PropValue.ts'; +import { requireNonEmptyString } from '../utils/scalarValidation.ts'; + +export type IntentKind = + | 'node.add' + | 'node.remove' + | 'edge.add' + | 'edge.remove' + | 'property.set' + | 'edgeProperty.set'; + +export type NodeIntentFields = { + readonly subject: string; +}; + +export type EdgeIntentFields = { + readonly from: string; + readonly to: string; + readonly label: string; +}; + +export type PropertyIntentFields = { + readonly subject: string; + readonly key: string; + readonly value: PropValue; +}; + +export type EdgePropertyIntentFields = EdgeIntentFields & { + readonly key: string; + readonly value: PropValue; +}; + +export type IntentDescriptor = + | (NodeIntentFields & { readonly kind: 'node.add' }) + | (NodeIntentFields & { readonly kind: 'node.remove' }) + | (EdgeIntentFields & { readonly kind: 'edge.add' }) + | (EdgeIntentFields & { readonly kind: 'edge.remove' }) + | (PropertyIntentFields & { readonly kind: 'property.set' }) + | (EdgePropertyIntentFields & { readonly kind: 'edgeProperty.set' }); + +const NODE_ADD: 'node.add' = 'node.add'; +const NODE_REMOVE: 'node.remove' = 'node.remove'; +const EDGE_ADD: 'edge.add' = 'edge.add'; +const EDGE_REMOVE: 'edge.remove' = 'edge.remove'; +const PROPERTY_SET: 'property.set' = 'property.set'; +const EDGE_PROPERTY_SET: 'edgeProperty.set' = 'edgeProperty.set'; + +export default class Intent { + readonly #descriptor: IntentDescriptor; + + constructor(descriptor: IntentDescriptor | null | undefined) { + this.#descriptor = normalizeDescriptor(descriptor); + Object.freeze(this); + } + + static addNode(fields: NodeIntentFields): Intent { + return new Intent(nodeDescriptor(NODE_ADD, fields)); + } + + static removeNode(fields: NodeIntentFields): Intent { + return new Intent(nodeDescriptor(NODE_REMOVE, fields)); + } + + static addEdge(fields: EdgeIntentFields): Intent { + return new Intent(edgeDescriptor(EDGE_ADD, fields)); + } + + static removeEdge(fields: EdgeIntentFields): Intent { + return new Intent(edgeDescriptor(EDGE_REMOVE, fields)); + } + + static setProperty(fields: PropertyIntentFields): Intent { + return new Intent(propertyDescriptor(fields)); + } + + static setEdgeProperty(fields: EdgePropertyIntentFields): Intent { + return new Intent(edgePropertyDescriptor(fields)); + } + + get kind(): IntentKind { + return this.#descriptor.kind; + } + + get descriptor(): IntentDescriptor { + return this.#descriptor; + } +} + +function normalizeDescriptor(descriptor: IntentDescriptor | null | undefined): IntentDescriptor { + return normalizeKnownDescriptor(requireDescriptor(descriptor)); +} + +function requireDescriptor(descriptor: IntentDescriptor | null | undefined): IntentDescriptor { + if (descriptor === null || descriptor === undefined) { + throw new WarpError('Intent descriptor is required', 'E_INTENT_DESCRIPTOR'); + } + return descriptor; +} + +function normalizeKnownDescriptor(descriptor: IntentDescriptor): IntentDescriptor { + if (isNodeDescriptor(descriptor)) { + return nodeDescriptor(descriptor.kind, descriptor); + } + if (isEdgeDescriptor(descriptor)) { + return edgeDescriptor(descriptor.kind, descriptor); + } + if (descriptor.kind === PROPERTY_SET) { + return propertyDescriptor(descriptor); + } + if (descriptor.kind === EDGE_PROPERTY_SET) { + return edgePropertyDescriptor(descriptor); + } + throw new WarpError('Intent kind is unsupported', 'E_INTENT_KIND'); +} + +function isNodeDescriptor( + descriptor: IntentDescriptor, +): descriptor is NodeIntentFields & { readonly kind: 'node.add' | 'node.remove' } { + return descriptor.kind === NODE_ADD || descriptor.kind === NODE_REMOVE; +} + +function isEdgeDescriptor( + descriptor: IntentDescriptor, +): descriptor is EdgeIntentFields & { readonly kind: 'edge.add' | 'edge.remove' } { + return descriptor.kind === EDGE_ADD || descriptor.kind === EDGE_REMOVE; +} + +function nodeDescriptor(kind: 'node.add' | 'node.remove', fields: NodeIntentFields): IntentDescriptor { + const checkedFields = requireIntentFields(fields); + requireNonEmptyString(checkedFields.subject, 'intent.subject'); + return Object.freeze({ kind, subject: checkedFields.subject }); +} + +function edgeDescriptor(kind: 'edge.add' | 'edge.remove', fields: EdgeIntentFields): IntentDescriptor { + const checkedFields = requireIntentFields(fields); + requireNonEmptyString(checkedFields.from, 'intent.from'); + requireNonEmptyString(checkedFields.to, 'intent.to'); + requireNonEmptyString(checkedFields.label, 'intent.label'); + return Object.freeze({ + kind, + from: checkedFields.from, + to: checkedFields.to, + label: checkedFields.label, + }); +} + +function propertyDescriptor(fields: PropertyIntentFields): IntentDescriptor { + const checkedFields = requireIntentFields(fields); + requireNonEmptyString(checkedFields.subject, 'intent.subject'); + requireNonEmptyString(checkedFields.key, 'intent.key'); + return Object.freeze({ + kind: PROPERTY_SET, + subject: checkedFields.subject, + key: checkedFields.key, + value: requireIntentValue(checkedFields.value), + }); +} + +function edgePropertyDescriptor(fields: EdgePropertyIntentFields): IntentDescriptor { + const checkedFields = requireIntentFields(fields); + requireNonEmptyString(checkedFields.from, 'intent.from'); + requireNonEmptyString(checkedFields.to, 'intent.to'); + requireNonEmptyString(checkedFields.label, 'intent.label'); + requireNonEmptyString(checkedFields.key, 'intent.key'); + return Object.freeze({ + kind: EDGE_PROPERTY_SET, + from: checkedFields.from, + to: checkedFields.to, + label: checkedFields.label, + key: checkedFields.key, + value: requireIntentValue(checkedFields.value), + }); +} + +function requireIntentFields(fields: TFields | null | undefined): TFields { + if (fields === null || fields === undefined) { + throw new WarpError('Intent fields are required', 'E_INTENT_FIELDS'); + } + return fields; +} + +function requireIntentValue(value: PropValue): PropValue { + if (isPropValue(value)) { + return value; + } + throw new WarpError('Intent value must be property-compatible data', 'E_INTENT_VALUE'); +} diff --git a/src/domain/api/IntentBuilders.ts b/src/domain/api/IntentBuilders.ts new file mode 100644 index 000000000..2bd1b8a3d --- /dev/null +++ b/src/domain/api/IntentBuilders.ts @@ -0,0 +1,40 @@ +import Intent, { + type EdgeIntentFields, + type EdgePropertyIntentFields, + type NodeIntentFields, + type PropertyIntentFields, +} from './Intent.ts'; + +export type IntentBuilders = { + readonly node: { + readonly add: (fields: NodeIntentFields) => Intent; + readonly remove: (fields: NodeIntentFields) => Intent; + }; + readonly edge: { + readonly add: (fields: EdgeIntentFields) => Intent; + readonly remove: (fields: EdgeIntentFields) => Intent; + }; + readonly property: { + readonly set: (fields: PropertyIntentFields) => Intent; + }; + readonly edgeProperty: { + readonly set: (fields: EdgePropertyIntentFields) => Intent; + }; +}; + +export const intent: IntentBuilders = Object.freeze({ + node: Object.freeze({ + add: (fields: NodeIntentFields) => Intent.addNode(fields), + remove: (fields: NodeIntentFields) => Intent.removeNode(fields), + }), + edge: Object.freeze({ + add: (fields: EdgeIntentFields) => Intent.addEdge(fields), + remove: (fields: EdgeIntentFields) => Intent.removeEdge(fields), + }), + property: Object.freeze({ + set: (fields: PropertyIntentFields) => Intent.setProperty(fields), + }), + edgeProperty: Object.freeze({ + set: (fields: EdgePropertyIntentFields) => Intent.setEdgeProperty(fields), + }), +}); diff --git a/src/domain/api/IntentRuntime.ts b/src/domain/api/IntentRuntime.ts new file mode 100644 index 000000000..5ce587ca9 --- /dev/null +++ b/src/domain/api/IntentRuntime.ts @@ -0,0 +1,69 @@ +import type { PatchBuilder } from '../services/PatchBuilder.ts'; +import type Intent from './Intent.ts'; +import type { IntentDescriptor, IntentKind } from './Intent.ts'; +import WarpError from '../errors/WarpError.ts'; + +type IntentLowerer = (descriptor: IntentDescriptor, patch: PatchBuilder) => void; + +const lowerers: ReadonlyMap = new Map([ + ['node.add', lowerNodeAdd], + ['node.remove', lowerNodeRemove], + ['edge.add', lowerEdgeAdd], + ['edge.remove', lowerEdgeRemove], + ['property.set', lowerPropertySet], + ['edgeProperty.set', lowerEdgePropertySet], +]); + +export function applyIntentToPatch(intent: Intent, patch: PatchBuilder): void { + const { descriptor } = intent; + const lowerer = lowerers.get(intent.kind); + if (lowerer === undefined) { + throw new WarpError('Intent kind is unsupported', 'E_INTENT_KIND'); + } + lowerer(descriptor, patch); +} + +function lowerNodeAdd(descriptor: IntentDescriptor, patch: PatchBuilder): void { + assertDescriptorKind(descriptor, 'node.add'); + patch.addNode(descriptor.subject); +} + +function lowerNodeRemove(descriptor: IntentDescriptor, patch: PatchBuilder): void { + assertDescriptorKind(descriptor, 'node.remove'); + patch.removeNode(descriptor.subject); +} + +function lowerEdgeAdd(descriptor: IntentDescriptor, patch: PatchBuilder): void { + assertDescriptorKind(descriptor, 'edge.add'); + patch.addEdge(descriptor.from, descriptor.to, descriptor.label); +} + +function lowerEdgeRemove(descriptor: IntentDescriptor, patch: PatchBuilder): void { + assertDescriptorKind(descriptor, 'edge.remove'); + patch.removeEdge(descriptor.from, descriptor.to, descriptor.label); +} + +function lowerPropertySet(descriptor: IntentDescriptor, patch: PatchBuilder): void { + assertDescriptorKind(descriptor, 'property.set'); + patch.setProperty(descriptor.subject, descriptor.key, descriptor.value); +} + +function lowerEdgePropertySet(descriptor: IntentDescriptor, patch: PatchBuilder): void { + assertDescriptorKind(descriptor, 'edgeProperty.set'); + patch.setEdgeProperty( + descriptor.from, + descriptor.to, + descriptor.label, + descriptor.key, + descriptor.value, + ); +} + +function assertDescriptorKind( + descriptor: IntentDescriptor, + kind: K, +): asserts descriptor is Extract { + if (descriptor.kind !== kind) { + throw new WarpError('Intent lowerer received a mismatched descriptor', 'E_INTENT_KIND'); + } +} diff --git a/src/domain/api/JoinReceipt.ts b/src/domain/api/JoinReceipt.ts new file mode 100644 index 000000000..8fd91018a --- /dev/null +++ b/src/domain/api/JoinReceipt.ts @@ -0,0 +1,78 @@ +import WarpError from '../errors/WarpError.ts'; +import { requireNonEmptyString } from '../utils/scalarValidation.ts'; +import DraftTimeline from './DraftTimeline.ts'; +import { RECEIPT_OUTCOMES, type ReceiptOutcome } from './WriteReceipt.ts'; + +export type JoinMode = 'preview' | 'join'; +export type JoinReceiptOutcome = ReceiptOutcome; + +export type JoinReceiptOptions = { + readonly timeline: string; + readonly writer: string; + readonly draft: DraftTimeline; + readonly mode: JoinMode; + readonly outcome: JoinReceiptOutcome; + readonly patchShas?: readonly string[]; + readonly reason?: string; +}; + +const JOIN_MODES: ReadonlySet = new Set(['preview', 'join']); +const JOIN_RECEIPT_OUTCOMES: ReadonlySet = RECEIPT_OUTCOMES; + +export default class JoinReceipt { + readonly draft: DraftTimeline; + readonly mode: JoinMode; + readonly outcome: JoinReceiptOutcome; + readonly patchShas: readonly string[]; + readonly reason: string | undefined; + readonly timeline: string; + readonly writer: string; + + constructor(options: JoinReceiptOptions | null | undefined) { + const fields = requireJoinReceiptOptions(options); + validateJoinReceiptFields(fields); + const patchShas = validatePatchShas(fields.patchShas ?? []); + + this.timeline = fields.timeline; + this.writer = fields.writer; + this.draft = fields.draft; + this.mode = fields.mode; + this.outcome = fields.outcome; + this.patchShas = patchShas; + this.reason = fields.reason; + Object.freeze(this); + } +} + +function validateJoinReceiptFields(fields: JoinReceiptOptions): void { + requireNonEmptyString(fields.timeline, 'joinReceipt.timeline'); + requireNonEmptyString(fields.writer, 'joinReceipt.writer'); + if (!(fields.draft instanceof DraftTimeline)) { + throw new WarpError('JoinReceipt requires a DraftTimeline', 'E_JOIN_RECEIPT_DRAFT'); + } + if (!JOIN_MODES.has(fields.mode)) { + throw new WarpError('JoinReceipt mode is unsupported', 'E_JOIN_RECEIPT_MODE'); + } + if (!JOIN_RECEIPT_OUTCOMES.has(fields.outcome)) { + throw new WarpError('JoinReceipt outcome is unsupported', 'E_JOIN_RECEIPT_OUTCOME'); + } + if (fields.reason !== undefined) { + requireNonEmptyString(fields.reason, 'joinReceipt.reason'); + } +} + +function requireJoinReceiptOptions(options: JoinReceiptOptions | null | undefined): JoinReceiptOptions { + if (options === null || options === undefined) { + throw new WarpError('JoinReceipt options are required', 'E_JOIN_RECEIPT_OPTIONS'); + } + return options; +} + +function validatePatchShas(patchShas: readonly string[]): readonly string[] { + const checked: string[] = []; + for (const patchSha of patchShas) { + requireNonEmptyString(patchSha, 'joinReceipt.patchSha'); + checked.push(patchSha); + } + return Object.freeze(checked); +} diff --git a/src/domain/api/JoinResult.ts b/src/domain/api/JoinResult.ts new file mode 100644 index 000000000..b57de39f1 --- /dev/null +++ b/src/domain/api/JoinResult.ts @@ -0,0 +1,26 @@ +import WarpError from '../errors/WarpError.ts'; +import JoinReceipt from './JoinReceipt.ts'; + +export type JoinResultOptions = { + readonly receipt: JoinReceipt; +}; + +export default class JoinResult { + readonly receipt: JoinReceipt; + + constructor(options: JoinResultOptions | null | undefined) { + const fields = requireJoinResultOptions(options); + if (!(fields.receipt instanceof JoinReceipt)) { + throw new WarpError('JoinResult requires a JoinReceipt', 'E_JOIN_RESULT_RECEIPT'); + } + this.receipt = fields.receipt; + Object.freeze(this); + } +} + +function requireJoinResultOptions(options: JoinResultOptions | null | undefined): JoinResultOptions { + if (options === null || options === undefined) { + throw new WarpError('JoinResult options are required', 'E_JOIN_RESULT_OPTIONS'); + } + return options; +} diff --git a/src/domain/api/ReadReceipt.ts b/src/domain/api/ReadReceipt.ts new file mode 100644 index 000000000..9b295b65a --- /dev/null +++ b/src/domain/api/ReadReceipt.ts @@ -0,0 +1,61 @@ +import WarpError from '../errors/WarpError.ts'; +import { requireNonEmptyString } from '../utils/scalarValidation.ts'; +import Reading from './Reading.ts'; + +export type ReadReceiptOutcome = + | 'resolved' + | 'obstructed' + | 'underdetermined' + | 'rejected'; + +export type ReadReceiptOptions = { + readonly timeline: string; + readonly writer: string; + readonly reading: Reading; + readonly outcome: ReadReceiptOutcome; + readonly reason?: string; +}; + +const READ_RECEIPT_OUTCOMES: ReadonlySet = new Set([ + 'resolved', + 'obstructed', + 'underdetermined', + 'rejected', +]); + +export default class ReadReceipt { + readonly outcome: ReadReceiptOutcome; + readonly reading: Reading; + readonly reason: string | undefined; + readonly timeline: string; + readonly writer: string; + + constructor(options: ReadReceiptOptions | null | undefined) { + const fields = requireReadReceiptOptions(options); + requireNonEmptyString(fields.timeline, 'readReceipt.timeline'); + requireNonEmptyString(fields.writer, 'readReceipt.writer'); + if (!(fields.reading instanceof Reading)) { + throw new WarpError('ReadReceipt requires a Reading', 'E_READ_RECEIPT_READING'); + } + if (!READ_RECEIPT_OUTCOMES.has(fields.outcome)) { + throw new WarpError('ReadReceipt outcome is unsupported', 'E_READ_RECEIPT_OUTCOME'); + } + if (fields.reason !== undefined) { + requireNonEmptyString(fields.reason, 'readReceipt.reason'); + } + + this.timeline = fields.timeline; + this.writer = fields.writer; + this.reading = fields.reading; + this.outcome = fields.outcome; + this.reason = fields.reason; + Object.freeze(this); + } +} + +function requireReadReceiptOptions(options: ReadReceiptOptions | null | undefined): ReadReceiptOptions { + if (options === null || options === undefined) { + throw new WarpError('ReadReceipt options are required', 'E_READ_RECEIPT_OPTIONS'); + } + return options; +} diff --git a/src/domain/api/Reading.ts b/src/domain/api/Reading.ts new file mode 100644 index 000000000..ef7cfe77f --- /dev/null +++ b/src/domain/api/Reading.ts @@ -0,0 +1,95 @@ +import WarpError from '../errors/WarpError.ts'; +import { requireNonEmptyString } from '../utils/scalarValidation.ts'; + +const PROPERTY_GET: 'property.get' = 'property.get'; +const NODE_EXISTS: 'node.exists' = 'node.exists'; + +export type ReadingKind = + | 'property.get' + | 'node.exists'; + +export type PropertyReadingFields = { + readonly subject: string; + readonly key: string; +}; + +export type NodeReadingFields = { + readonly subject: string; +}; + +export type ReadingDescriptor = + | (PropertyReadingFields & { readonly kind: 'property.get' }) + | (NodeReadingFields & { readonly kind: 'node.exists' }); + +export default class Reading { + readonly #descriptor: ReadingDescriptor; + + constructor(descriptor: ReadingDescriptor | null | undefined) { + this.#descriptor = normalizeDescriptor(descriptor); + Object.freeze(this); + } + + static property(fields: PropertyReadingFields): Reading { + return new Reading(propertyDescriptor(fields)); + } + + static nodeExists(fields: NodeReadingFields): Reading { + return new Reading(nodeExistsDescriptor(fields)); + } + + get kind(): ReadingKind { + return this.#descriptor.kind; + } + + get descriptor(): ReadingDescriptor { + return this.#descriptor; + } +} + +function normalizeDescriptor(descriptor: ReadingDescriptor | null | undefined): ReadingDescriptor { + return normalizeKnownDescriptor(requireDescriptor(descriptor)); +} + +function requireDescriptor(descriptor: ReadingDescriptor | null | undefined): ReadingDescriptor { + if (descriptor === null || descriptor === undefined) { + throw new WarpError('Reading descriptor is required', 'E_READING_DESCRIPTOR'); + } + return descriptor; +} + +function normalizeKnownDescriptor(descriptor: ReadingDescriptor): ReadingDescriptor { + if (descriptor.kind === PROPERTY_GET) { + return propertyDescriptor(descriptor); + } + if (descriptor.kind === NODE_EXISTS) { + return nodeExistsDescriptor(descriptor); + } + throw new WarpError('Reading kind is unsupported', 'E_READING_KIND'); +} + +function propertyDescriptor(fields: PropertyReadingFields): ReadingDescriptor { + const checkedFields = requireReadingFields(fields); + requireNonEmptyString(checkedFields.subject, 'reading.subject'); + requireNonEmptyString(checkedFields.key, 'reading.key'); + return Object.freeze({ + kind: PROPERTY_GET, + subject: checkedFields.subject, + key: checkedFields.key, + }); +} + +function nodeExistsDescriptor(fields: NodeReadingFields): ReadingDescriptor { + const checkedFields = requireReadingFields(fields); + requireNonEmptyString(checkedFields.subject, 'reading.subject'); + return Object.freeze({ + kind: NODE_EXISTS, + subject: checkedFields.subject, + }); +} + +function requireReadingFields(fields: TFields | null | undefined): TFields { + if (fields === null || fields === undefined) { + throw new WarpError('Reading fields are required', 'E_READING_FIELDS'); + } + return fields; +} diff --git a/src/domain/api/ReadingBuilders.ts b/src/domain/api/ReadingBuilders.ts new file mode 100644 index 000000000..4f16cbaf0 --- /dev/null +++ b/src/domain/api/ReadingBuilders.ts @@ -0,0 +1,20 @@ +import Reading, { + type NodeReadingFields, + type PropertyReadingFields, +} from './Reading.ts'; + +export type ReadingBuilders = { + readonly property: (fields: PropertyReadingFields) => Reading; + readonly node: { + readonly exists: (fields: NodeReadingFields) => Reading; + }; +}; + +export const reading: ReadingBuilders = Object.freeze({ + property: (fields: PropertyReadingFields) => Reading.property(fields), + node: Object.freeze({ + exists: (fields: NodeReadingFields) => Reading.nodeExists(fields), + }), +}); + +export default reading; diff --git a/src/domain/api/ReadingResult.ts b/src/domain/api/ReadingResult.ts new file mode 100644 index 000000000..5e18147b0 --- /dev/null +++ b/src/domain/api/ReadingResult.ts @@ -0,0 +1,135 @@ +import WarpError from '../errors/WarpError.ts'; +import ImmutableBytes from '../services/snapshot/ImmutableBytes.ts'; +import type { SnapshotPropValue } from '../services/snapshot/SnapshotPropValue.ts'; +import ReadReceipt from './ReadReceipt.ts'; + +export type ReadingValue = SnapshotPropValue; + +const FORBIDDEN_READING_VALUE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +export type ReadingResultOptions = { + readonly value: TValue; + readonly receipt: ReadReceipt; +}; + +export default class ReadingResult { + readonly receipt: ReadReceipt; + readonly value: TValue; + + constructor(options: ReadingResultOptions | null | undefined) { + const fields = requireReadingResultOptions(options); + if (!isReadingValue(fields.value)) { + throw new WarpError('ReadingResult value must be snapshot-compatible data', 'E_READING_RESULT_VALUE'); + } + if (!(fields.receipt instanceof ReadReceipt)) { + throw new WarpError('ReadingResult requires a ReadReceipt', 'E_READING_RESULT_RECEIPT'); + } + + this.value = fields.value; + this.receipt = fields.receipt; + Object.freeze(this); + } +} + +function isScalarReadingValue( + value: T, +): value is T & (string | number | boolean | null | Uint8Array | ImmutableBytes) { + return value === null + || isPrimitiveReadingValue(value) + || value instanceof Uint8Array + || value instanceof ImmutableBytes; +} + +function isPrimitiveReadingValue(value: T): value is T & (string | number | boolean) { + return typeof value === 'string' + || typeof value === 'number' + || typeof value === 'boolean'; +} + +function isReadingValueArray( + value: T, + seen: WeakSet, +): value is T & readonly ReadingValue[] { + if (!Array.isArray(value)) { + return false; + } + if (seen.has(value)) { + return false; + } + seen.add(value); + try { + return value.every((entry) => isReadingValueWithSeen(entry, seen)); + } finally { + seen.delete(value); + } +} + +function isReadingValueObjectCandidate(value: T): value is T & object { + if (value === null || typeof value !== 'object') { + return false; + } + return isNonArrayPlainObject(value); +} + +function isForbiddenReadingValueKey(key: string): boolean { + return FORBIDDEN_READING_VALUE_KEYS.has(key); +} + +function canTraverseReadingValueObject(value: T, seen: WeakSet): value is T & object { + return isReadingValueObjectCandidate(value) && !seen.has(value); +} + +function isReadingValueObject( + value: T, + seen: WeakSet, +): value is T & { readonly [key: string]: ReadingValue } { + if (!canTraverseReadingValueObject(value, seen)) { + return false; + } + seen.add(value); + try { + return readingValueObjectEntriesAreValid(value, seen); + } finally { + seen.delete(value); + } +} + +function readingValueObjectEntriesAreValid(value: object, seen: WeakSet): boolean { + for (const [key, entry] of Object.entries(value)) { + if (isForbiddenReadingValueKey(key) || !isReadingValueWithSeen(entry, seen)) { + return false; + } + } + return true; +} + +function isNonArrayPlainObject(value: object): boolean { + if ( + Array.isArray(value) + || value instanceof Uint8Array + || value instanceof ImmutableBytes + ) { + return false; + } + return Object.getPrototypeOf(value) === Object.prototype + || Object.getPrototypeOf(value) === null; +} + +function isReadingValue(value: T): value is T & ReadingValue { + return isReadingValueWithSeen(value, new WeakSet()); +} + +function isReadingValueWithSeen(value: T, seen: WeakSet): value is T & ReadingValue { + return isScalarReadingValue(value) + || isReadingValueArray(value, seen) + || isReadingValueObject(value, seen); +} + +function requireReadingResultOptions( + options: ReadingResultOptions | null | undefined, +): ReadingResultOptions { + if (options === null || options === undefined) { + throw new WarpError('ReadingResult options are required', 'E_READING_RESULT_OPTIONS'); + } + return options; +} diff --git a/src/domain/api/ReadingRuntime.ts b/src/domain/api/ReadingRuntime.ts new file mode 100644 index 000000000..4c7adf6c6 --- /dev/null +++ b/src/domain/api/ReadingRuntime.ts @@ -0,0 +1,62 @@ +import type WarpWorldline from '../WarpWorldline.ts'; +import WarpError from '../errors/WarpError.ts'; +import type { SnapshotPropValue } from '../services/snapshot/SnapshotPropValue.ts'; +import type Reading from './Reading.ts'; +import type { + ReadingDescriptor, + ReadingKind, +} from './Reading.ts'; +import ReadReceipt from './ReadReceipt.ts'; +import ReadingResult from './ReadingResult.ts'; + +type ReadingExecutor = ( + descriptor: ReadingDescriptor, + runtime: WarpWorldline, +) => Promise; + +const readers: ReadonlyMap = new Map([ + ['property.get', readProperty], + ['node.exists', readNodeExists], +]); + +export async function executeReading( + runtime: WarpWorldline, + reading: Reading, +): Promise { + const { descriptor } = reading; + const reader = readers.get(reading.kind); + if (reader === undefined) { + throw new WarpError('Reading kind is unsupported', 'E_READING_KIND'); + } + const value = await reader(descriptor, runtime); + return new ReadingResult({ + value, + receipt: new ReadReceipt({ + timeline: runtime.worldlineName, + writer: runtime.writerId, + reading, + outcome: 'resolved', + }), + }); +} + +async function readProperty( + descriptor: ReadingDescriptor, + runtime: WarpWorldline, +): Promise { + if (descriptor.kind !== 'property.get') { + throw new WarpError('Reading executor received a mismatched descriptor', 'E_READING_KIND'); + } + const props = await runtime.live().getNodeProps(descriptor.subject); + return props?.[descriptor.key] ?? null; +} + +async function readNodeExists( + descriptor: ReadingDescriptor, + runtime: WarpWorldline, +): Promise { + if (descriptor.kind !== 'node.exists') { + throw new WarpError('Reading executor received a mismatched descriptor', 'E_READING_KIND'); + } + return await runtime.live().hasNode(descriptor.subject); +} diff --git a/src/domain/api/Timeline.ts b/src/domain/api/Timeline.ts index dc1003e0f..4eb4dfe70 100644 --- a/src/domain/api/Timeline.ts +++ b/src/domain/api/Timeline.ts @@ -1,11 +1,41 @@ import WarpError from '../errors/WarpError.ts'; import { assertTimelineNameIdentity, assertWriterIdentity } from './assertIdentity.ts'; +import DraftTimeline from './DraftTimeline.ts'; +import Intent from './Intent.ts'; +import type JoinResult from './JoinResult.ts'; +import Reading from './Reading.ts'; +import type ReadingResult from './ReadingResult.ts'; +import type WriteReceipt from './WriteReceipt.ts'; + +const DETERMINISTIC_JOIN_POLICY: 'deterministic' = 'deterministic'; +const DEFAULT_JOIN_OPTIONS: JoinOptions = Object.freeze({ policy: DETERMINISTIC_JOIN_POLICY }); + +export type JoinPolicy = 'deterministic'; + +export type JoinOptions = { + readonly policy?: JoinPolicy; +}; type TimelineConstructionOptions = { readonly name: string; readonly writer: string; + readonly joinDraft?: JoinDraft; + readonly openDraft?: OpenDraft; + readonly previewJoinDraft?: JoinDraft; + readonly readReading?: ReadReading; + readonly writeIntent?: WriteIntent; }; +type JoinDraft = (draft: DraftTimeline, options: JoinOptions) => Promise; +type OpenDraft = (name: string) => Promise; +type ReadReading = (reading: Reading) => Promise; +type WriteIntent = (intent: Intent) => Promise; +type TimelinePortErrorCode = + | 'E_TIMELINE_JOINER' + | 'E_TIMELINE_DRAFT_OPENER' + | 'E_TIMELINE_READER' + | 'E_TIMELINE_WRITER'; + /** * Public timeline handle for application workflows. * @@ -14,7 +44,12 @@ type TimelineConstructionOptions = { * root API contract. */ export default class Timeline { + readonly #joinDraft: JoinDraft | null; readonly #name: string; + readonly #openDraft: OpenDraft | null; + readonly #previewJoinDraft: JoinDraft | null; + readonly #readReading: ReadReading | null; + readonly #writeIntent: WriteIntent | null; readonly #writer: string; constructor(options: TimelineConstructionOptions) { @@ -27,8 +62,13 @@ export default class Timeline { message: 'Timeline requires non-empty identity fields', code: 'E_TIMELINE_IDENTITY', }); + this.#joinDraft = optionalPort(options.joinDraft); this.#name = options.name; + this.#openDraft = optionalPort(options.openDraft); + this.#previewJoinDraft = optionalPort(options.previewJoinDraft); this.#writer = options.writer; + this.#readReading = optionalPort(options.readReading); + this.#writeIntent = optionalPort(options.writeIntent); Object.freeze(this); } @@ -39,6 +79,71 @@ export default class Timeline { get writer(): string { return this.#writer; } + + async draft(name: string): Promise { + assertTimelineNameIdentity(name, 'name', { + message: 'Timeline.draft requires non-empty identity fields', + code: 'E_TIMELINE_DRAFT_IDENTITY', + }); + if (this.#openDraft === null) { + throw new WarpError('Timeline was not opened by openWarp', 'E_TIMELINE_RUNTIME_UNAVAILABLE'); + } + return await this.#openDraft(name); + } + + async previewJoin(draft: DraftTimeline, options?: JoinOptions): Promise { + assertDraftTimeline(draft); + if (this.#previewJoinDraft === null) { + throw new WarpError('Timeline was not opened by openWarp', 'E_TIMELINE_RUNTIME_UNAVAILABLE'); + } + return await this.#previewJoinDraft(draft, normalizeJoinOptions(options)); + } + + async join(draft: DraftTimeline, options?: JoinOptions): Promise { + assertDraftTimeline(draft); + if (this.#joinDraft === null) { + throw new WarpError('Timeline was not opened by openWarp', 'E_TIMELINE_RUNTIME_UNAVAILABLE'); + } + return await this.#joinDraft(draft, normalizeJoinOptions(options)); + } + + async read(reading: Reading): Promise { + assertReadingInstance(reading); + if (this.#readReading === null) { + throw new WarpError('Timeline was not opened by openWarp', 'E_TIMELINE_RUNTIME_UNAVAILABLE'); + } + return await this.#readReading(reading); + } + + async write(intent: Intent): Promise { + assertIntentInstance(intent); + if (this.#writeIntent === null) { + throw new WarpError('Timeline was not opened by openWarp', 'E_TIMELINE_RUNTIME_UNAVAILABLE'); + } + return await this.#writeIntent(intent); + } +} + +function assertDraftTimeline(draft: DraftTimeline): void { + if (!(draft instanceof DraftTimeline)) { + throw new WarpError('Timeline join operations require a DraftTimeline', 'E_TIMELINE_JOIN_DRAFT'); + } +} + +function assertReadingInstance(reading: Reading): void { + if (!(reading instanceof Reading)) { + throw new WarpError('Timeline.read requires a Reading', 'E_TIMELINE_READ_READING'); + } +} + +function assertIntentInstance(intent: Intent): void { + if (!(intent instanceof Intent)) { + throw new WarpError('Timeline.write requires an Intent', 'E_TIMELINE_WRITE_INTENT'); + } +} + +function optionalPort(port: TPort | undefined): TPort | null { + return port ?? null; } function assertTimelineConstructionOptions(options: TimelineConstructionOptions): void { @@ -48,4 +153,75 @@ function assertTimelineConstructionOptions(options: TimelineConstructionOptions) 'E_TIMELINE_CONSTRUCTION_OPTIONS', ); } + assertJoinDraft(options.joinDraft); + assertOpenDraft(options.openDraft); + assertJoinDraft(options.previewJoinDraft); + assertReadReading(options.readReading); + assertWriteIntent(options.writeIntent); +} + +function assertJoinDraft(joinDraft: JoinDraft | undefined): void { + assertOptionalFunctionPort( + joinDraft, + 'Timeline requires a join function when provided', + 'E_TIMELINE_JOINER', + ); +} + +function assertOpenDraft(openDraft: OpenDraft | undefined): void { + assertOptionalFunctionPort( + openDraft, + 'Timeline requires an openDraft function when provided', + 'E_TIMELINE_DRAFT_OPENER', + ); +} + +function assertReadReading(readReading: ReadReading | undefined): void { + assertOptionalFunctionPort( + readReading, + 'Timeline requires a readReading function when provided', + 'E_TIMELINE_READER', + ); +} + +function normalizeJoinOptions(options: JoinOptions | null | undefined): JoinOptions { + if (options === undefined) { + return DEFAULT_JOIN_OPTIONS; + } + assertJoinOptionsObject(options); + assertNoDryRunTrap(options); + if (options.policy !== undefined && options.policy !== DETERMINISTIC_JOIN_POLICY) { + throw new WarpError('Timeline join policy is unsupported', 'E_TIMELINE_JOIN_POLICY'); + } + return Object.freeze({ policy: options.policy ?? DETERMINISTIC_JOIN_POLICY }); +} + +function assertJoinOptionsObject(options: JoinOptions | null): asserts options is JoinOptions { + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + throw new WarpError('Timeline join options must be an object', 'E_TIMELINE_JOIN_OPTIONS'); + } +} + +function assertNoDryRunTrap(options: JoinOptions): void { + if ('dryRun' in options) { + throw new WarpError('Use Timeline.previewJoin() instead of a dryRun option', 'E_TIMELINE_JOIN_DRY_RUN'); + } +} + +function assertWriteIntent(writeIntent: WriteIntent | undefined): void { + assertOptionalFunctionPort( + writeIntent, + 'Timeline requires a writeIntent function when provided', + 'E_TIMELINE_WRITER', + ); +} + +function assertOptionalFunctionPort( + port: TPort | undefined, + message: string, + code: TimelinePortErrorCode, +): void { + if (port !== undefined && typeof port !== 'function') { + throw new WarpError(message, code); + } } diff --git a/src/domain/api/TimelineRuntime.ts b/src/domain/api/TimelineRuntime.ts index b215f532c..71c027f0e 100644 --- a/src/domain/api/TimelineRuntime.ts +++ b/src/domain/api/TimelineRuntime.ts @@ -1,6 +1,14 @@ import WarpError from '../errors/WarpError.ts'; import type WarpWorldline from '../WarpWorldline.ts'; +import { + createDraftTimeline, + joinDraftTimeline, + previewDraftJoin, +} from './DraftTimelineRuntime.ts'; +import { applyIntentToPatch } from './IntentRuntime.ts'; +import { executeReading } from './ReadingRuntime.ts'; import Timeline from './Timeline.ts'; +import WriteReceipt from './WriteReceipt.ts'; const timelineRuntimes = new WeakMap(); @@ -8,6 +16,22 @@ export function createTimeline(runtime: WarpWorldline): Timeline { const timeline = new Timeline({ name: runtime.worldlineName, writer: runtime.writerId, + joinDraft: (draft, options) => joinDraftTimeline(runtime, draft, options), + openDraft: (name) => createDraftTimeline(runtime, runtime.worldlineName, name), + previewJoinDraft: (draft, options) => previewDraftJoin(runtime, draft, options), + readReading: (reading) => executeReading(runtime, reading), + writeIntent: async (intent) => { + const patchSha = await runtime.commit((patch) => { + applyIntentToPatch(intent, patch); + }); + return new WriteReceipt({ + timeline: runtime.worldlineName, + writer: runtime.writerId, + intent, + outcome: 'accepted', + patchSha, + }); + }, }); timelineRuntimes.set(timeline, runtime); return timeline; diff --git a/src/domain/api/WriteReceipt.ts b/src/domain/api/WriteReceipt.ts new file mode 100644 index 000000000..88d1f963e --- /dev/null +++ b/src/domain/api/WriteReceipt.ts @@ -0,0 +1,67 @@ +import WarpError from '../errors/WarpError.ts'; +import { requireNonEmptyString } from '../utils/scalarValidation.ts'; +import Intent from './Intent.ts'; + +export type ReceiptOutcome = + | 'accepted' + | 'obstructed' + | 'conflicted' + | 'underdetermined' + | 'rejected'; + +export type WriteReceiptOptions = { + readonly timeline: string; + readonly writer: string; + readonly intent: Intent; + readonly outcome: ReceiptOutcome; + readonly patchSha: string; + readonly reason?: string; +}; + +export const RECEIPT_OUTCOMES: ReadonlySet = new Set([ + 'accepted', + 'obstructed', + 'conflicted', + 'underdetermined', + 'rejected', +]); + +export default class WriteReceipt { + readonly intent: Intent; + readonly outcome: ReceiptOutcome; + readonly patchSha: string; + readonly reason: string | undefined; + readonly timeline: string; + readonly writer: string; + + constructor(options: WriteReceiptOptions | null | undefined) { + const fields = requireWriteReceiptOptions(options); + requireNonEmptyString(fields.timeline, 'writeReceipt.timeline'); + requireNonEmptyString(fields.writer, 'writeReceipt.writer'); + requireNonEmptyString(fields.patchSha, 'writeReceipt.patchSha'); + if (!(fields.intent instanceof Intent)) { + throw new WarpError('WriteReceipt requires an Intent', 'E_WRITE_RECEIPT_INTENT'); + } + if (!RECEIPT_OUTCOMES.has(fields.outcome)) { + throw new WarpError('WriteReceipt outcome is unsupported', 'E_WRITE_RECEIPT_OUTCOME'); + } + if (fields.reason !== undefined) { + requireNonEmptyString(fields.reason, 'writeReceipt.reason'); + } + + this.timeline = fields.timeline; + this.writer = fields.writer; + this.intent = fields.intent; + this.outcome = fields.outcome; + this.patchSha = fields.patchSha; + this.reason = fields.reason; + Object.freeze(this); + } +} + +function requireWriteReceiptOptions(options: WriteReceiptOptions | null | undefined): WriteReceiptOptions { + if (options === null || options === undefined) { + throw new WarpError('WriteReceipt options are required', 'E_WRITE_RECEIPT_OPTIONS'); + } + return options; +} diff --git a/test/type-check/tsconfig.json b/test/type-check/tsconfig.json index d2757ce2c..6baa47349 100644 --- a/test/type-check/tsconfig.json +++ b/test/type-check/tsconfig.json @@ -8,5 +8,11 @@ "noUnusedLocals": false, "noUnusedParameters": false }, - "include": ["consumer.ts", "runtime-declarations.d.ts", "v19-consumer.ts"] + "include": [ + "consumer.ts", + "runtime-declarations.d.ts", + "v19-consumer.ts", + "v19-first-use.ts", + "v19-subpaths.ts" + ] } diff --git a/test/type-check/v19-consumer.ts b/test/type-check/v19-consumer.ts index b71a45e2b..29fcca505 100644 --- a/test/type-check/v19-consumer.ts +++ b/test/type-check/v19-consumer.ts @@ -5,10 +5,25 @@ */ import { + DraftTimeline, + intent, + Intent, + JoinReceipt, + JoinResult, openWarp, + reading, + Reading, + ReadingResult, + ReadReceipt, Timeline, Warp, + WriteReceipt, type OpenWarpOptions, + type JoinOptions, + type JoinPolicy, + type ReadReceiptOutcome, + type ReadingValue, + type ReceiptOutcome, type WarpStorage, } from '../../index.ts'; import { MemoryStorageAdapter } from '../../storage.ts'; @@ -25,6 +40,37 @@ const warp: Warp = await openWarp(options); const timeline: Timeline = await warp.timeline('events'); const timelineName: string = timeline.name; const timelineWriter: string = timeline.writer; +const writeIntent: Intent = intent.property.set({ + subject: 'user:alice', + key: 'role', + value: 'admin', +}); +const receipt: WriteReceipt = await timeline.write(writeIntent); +const outcome: ReceiptOutcome = receipt.outcome; +const readRequest: Reading = reading.property({ + subject: 'user:alice', + key: 'role', +}); +const readResult: ReadingResult = await timeline.read(readRequest); +const readValue: ReadingValue = readResult.value; +const readReceipt: ReadReceipt = readResult.receipt; +const readOutcome: ReadReceiptOutcome = readReceipt.outcome; +const draft: DraftTimeline = await timeline.draft('try-admin-role'); +const draftReceipt: WriteReceipt = await draft.write(writeIntent); +const joinPolicy: JoinPolicy = 'deterministic'; +const joinOptions: JoinOptions = { policy: joinPolicy }; +const preview: JoinResult = await timeline.previewJoin(draft, joinOptions); +const joined: JoinResult = await timeline.join(draft); +const joinReceipt: JoinReceipt = joined.receipt; + +// @ts-expect-error receipt outcomes do not expose operation names. +const operationOutcome: ReceiptOutcome = 'write'; + +// @ts-expect-error read receipt outcomes do not expose operation names. +const readOperationOutcome: ReadReceiptOutcome = 'read'; + +// @ts-expect-error previewJoin is a dedicated method, not a dryRun boolean trap. +await timeline.previewJoin(draft, { dryRun: true }); // @ts-expect-error timelines do not expose legacy worldline names. timeline.worldlineName; @@ -37,3 +83,11 @@ timeline.commit; void timelineName; void timelineWriter; +void outcome; +void operationOutcome; +void readValue; +void readOutcome; +void readOperationOutcome; +void draftReceipt; +void preview; +void joinReceipt; diff --git a/test/type-check/v19-first-use.ts b/test/type-check/v19-first-use.ts new file mode 100644 index 000000000..ddc152311 --- /dev/null +++ b/test/type-check/v19-first-use.ts @@ -0,0 +1,35 @@ +/** + * v19 first-use consumer fixture -- compile-only. + * + * This fixture intentionally imports only the root first-use verbs and storage + * subpath adapter. Advanced, diagnostic, and legacy nouns are out-of-band. + */ + +import { intent, openWarp, reading } from '../../index.ts'; +import { MemoryStorageAdapter } from '../../storage.ts'; + +const warp = await openWarp({ + storage: new MemoryStorageAdapter(), + writer: 'agent-1', +}); +const timeline = await warp.timeline('events'); + +await timeline.write(intent.node.add({ subject: 'user:alice' })); +await timeline.write(intent.property.set({ + subject: 'user:alice', + key: 'role', + value: 'admin', +})); + +const role = await timeline.read(reading.property({ + subject: 'user:alice', + key: 'role', +})); +const userExists = await timeline.read(reading.node.exists({ + subject: 'user:alice', +})); + +void role.value; +void role.receipt; +void userExists.value; +void userExists.receipt; diff --git a/test/type-check/v19-subpaths.ts b/test/type-check/v19-subpaths.ts new file mode 100644 index 000000000..a8f44b50d --- /dev/null +++ b/test/type-check/v19-subpaths.ts @@ -0,0 +1,59 @@ +/** + * v19 explicit subpath consumer fixture -- compile-only. + * + * Storage, advanced, diagnostics, and legacy imports stay reachable only from + * their named compatibility surfaces. + */ + +import { + GitStorageAdapter, + MemoryStorageAdapter, + NodeCryptoAdapter, +} from '../../storage.ts'; +import { + GitWarpTickHologram, + Observer, + Optic, + ProjectionHandle, +} from '../../advanced.ts'; +import { + QueryBuilder, + TtdMergeInspector, + normalizeVisibleStateScope, +} from '../../diagnostics.ts'; +import { + InMemoryGraphAdapter, + PatchBuilder, + WarpWorldline, + openWarpGraph, + openWarpWorldline, +} from '../../legacy.ts'; + +const storageAdapter: typeof MemoryStorageAdapter = MemoryStorageAdapter; +const gitStorageAdapter: typeof GitStorageAdapter = GitStorageAdapter; +const nodeCryptoAdapter: typeof NodeCryptoAdapter = NodeCryptoAdapter; +const observer: typeof Observer = Observer; +const optic: typeof Optic = Optic; +const projectionHandle: typeof ProjectionHandle = ProjectionHandle; +const tickHologram: typeof GitWarpTickHologram = GitWarpTickHologram; +const queryBuilder: typeof QueryBuilder = QueryBuilder; +const ttdMergeInspector: typeof TtdMergeInspector = TtdMergeInspector; +const legacyAdapter: typeof InMemoryGraphAdapter = InMemoryGraphAdapter; +const patchBuilder: typeof PatchBuilder = PatchBuilder; +const worldline: typeof WarpWorldline = WarpWorldline; + +void storageAdapter; +void gitStorageAdapter; +void nodeCryptoAdapter; +void observer; +void optic; +void projectionHandle; +void tickHologram; +void queryBuilder; +void ttdMergeInspector; +void normalizeVisibleStateScope; +void legacyAdapter; +void patchBuilder; +void worldline; +void openWarpGraph; +void openWarpWorldline; diff --git a/test/unit/domain/DraftTimelineRuntime.test.ts b/test/unit/domain/DraftTimelineRuntime.test.ts new file mode 100644 index 000000000..ccbe7e3cd --- /dev/null +++ b/test/unit/domain/DraftTimelineRuntime.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; + +import WarpWorldline, { type WarpWorldlinePatchBuild } from '../../../src/domain/WarpWorldline.ts'; +import { + createDraftTimeline, + joinDraftTimeline, + previewDraftJoin, +} from '../../../src/domain/api/DraftTimelineRuntime.ts'; +import { intent } from '../../../src/domain/api/IntentBuilders.ts'; + +type RuntimeOptions = { + readonly commitPatch?: (build: WarpWorldlinePatchBuild) => Promise; + readonly previewDraftJoin?: (name: string) => Promise; +}; + +function createRuntime(options: RuntimeOptions = {}): WarpWorldline { + return new WarpWorldline({ + worldlineName: 'events', + writerId: 'agent-1', + commitPatch: options.commitPatch ?? (async () => 'commit-1'), + createDraft: async () => undefined, + createWorldline: () => { + throw new Error('ProjectionHandle is not used by DraftTimelineRuntime tests'); + }, + patchDraft: async (name) => `${name}-draft-patch`, + previewDraftJoin: options.previewDraftJoin ?? (async (name) => [`${name}-preview-patch`]), + admitIntent: async (descriptor) => ({ + admitted: true, + sha: 'intent-sha', + intentId: descriptor.intentId, + }), + }); +} + +describe('DraftTimelineRuntime', () => { + it('rejects concurrent joins without double-committing draft intents', async () => { + let commitAttempts = 0; + let releaseFirstCommit = (): void => undefined; + let markFirstCommitStarted = (): void => undefined; + const firstCommitStarted = new Promise((resolve) => { + markFirstCommitStarted = resolve; + }); + const firstCommitRelease = new Promise((resolve) => { + releaseFirstCommit = resolve; + }); + const runtime = createRuntime({ + commitPatch: async () => { + commitAttempts += 1; + if (commitAttempts === 1) { + markFirstCommitStarted(); + await firstCommitRelease; + } + return `join-patch-${commitAttempts}`; + }, + }); + const draft = await createDraftTimeline(runtime, 'events', 'try-admin-role'); + + await draft.write(intent.node.add({ subject: 'user:alice' })); + const firstJoin = joinDraftTimeline(runtime, draft, { policy: 'deterministic' }); + await firstCommitStarted; + const secondJoin = await joinDraftTimeline(runtime, draft, { policy: 'deterministic' }); + releaseFirstCommit(); + const firstResult = await firstJoin; + + expect(firstResult.receipt.outcome).toBe('accepted'); + expect(secondJoin.receipt.outcome).toBe('rejected'); + expect(secondJoin.receipt.reason).toBe('Draft join is already in progress'); + expect(commitAttempts).toBe(1); + }); + + it('reports preview patch shas from runtime materialization', async () => { + const runtime = createRuntime({ + previewDraftJoin: async () => ['materialized-preview-patch'], + }); + const draft = await createDraftTimeline(runtime, 'events', 'try-admin-role'); + + await draft.write(intent.node.add({ subject: 'user:alice' })); + const preview = await previewDraftJoin(runtime, draft, { policy: 'deterministic' }); + + expect(preview.receipt.outcome).toBe('accepted'); + expect(preview.receipt.patchShas).toEqual(['materialized-preview-patch']); + }); + + it('does not replay committed draft intents after a partial join failure', async () => { + let commitAttempts = 0; + const runtime = createRuntime({ + commitPatch: async () => { + commitAttempts += 1; + if (commitAttempts === 2) { + throw new Error('deterministic commit failure'); + } + return `join-patch-${commitAttempts}`; + }, + }); + const draft = await createDraftTimeline(runtime, 'events', 'try-admin-role'); + + await draft.write(intent.node.add({ subject: 'user:alice' })); + await draft.write(intent.property.set({ + subject: 'user:alice', + key: 'role', + value: 'admin', + })); + + const failedJoin = await joinDraftTimeline(runtime, draft, { policy: 'deterministic' }); + const retryJoin = await joinDraftTimeline(runtime, draft, { policy: 'deterministic' }); + + expect(failedJoin.receipt.outcome).toBe('rejected'); + expect(failedJoin.receipt.patchShas).toEqual(['join-patch-1']); + expect(failedJoin.receipt.reason).toBe('Draft join failed while committing intents'); + expect(retryJoin.receipt.outcome).toBe('rejected'); + expect(retryJoin.receipt.patchShas).toEqual(['join-patch-1']); + expect(retryJoin.receipt.reason).toBe('Draft join already has a failed commit attempt'); + expect(commitAttempts).toBe(2); + }); +}); diff --git a/test/unit/domain/ReceiptOutcome.test.ts b/test/unit/domain/ReceiptOutcome.test.ts new file mode 100644 index 000000000..ddd837458 --- /dev/null +++ b/test/unit/domain/ReceiptOutcome.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import DraftTimeline from '../../../src/domain/api/DraftTimeline.ts'; +import JoinReceipt from '../../../src/domain/api/JoinReceipt.ts'; +import { RECEIPT_OUTCOMES } from '../../../src/domain/api/WriteReceipt.ts'; + +describe('receipt outcomes', () => { + it('shares the canonical write receipt outcomes with join receipts', () => { + const draft = new DraftTimeline({ + name: 'try-admin-role', + timeline: 'events', + writer: 'agent-1', + }); + + for (const outcome of RECEIPT_OUTCOMES) { + const receipt = new JoinReceipt({ + timeline: 'events', + writer: 'agent-1', + draft, + mode: 'join', + outcome, + }); + + expect(receipt.outcome).toBe(outcome); + } + }); +}); diff --git a/test/unit/domain/WarpFacade.test.ts b/test/unit/domain/WarpFacade.test.ts index 06f68f761..4b33c87b8 100644 --- a/test/unit/domain/WarpFacade.test.ts +++ b/test/unit/domain/WarpFacade.test.ts @@ -3,14 +3,63 @@ import ts from 'typescript'; import { describe, expect, it } from 'vitest'; import { + DraftTimeline, + intent, + JoinReceipt, + JoinResult, openWarp, + reading, + ReadReceipt, + ReadingResult, Timeline, Warp, } from '../../../index.ts'; import { OPEN_WARP_IDENTITY_FAILURE } from '../../../src/domain/api/OpenWarpIdentityFailure.ts'; +import { requireTimelineRuntime } from '../../../src/domain/api/TimelineRuntime.ts'; import { MAX_WRITER_ID_LENGTH } from '../../../src/domain/utils/RefLayout.ts'; import { MemoryStorageAdapter } from '../../../storage.ts'; +const FORBIDDEN_ROOT_SUBSTRATE_EXPORTS = Object.freeze([ + 'openWarpGraph', + 'openWarpWorldline', + 'WarpGraph', + 'WarpWorldline', + 'WarpWorldlineOpticBasis', + 'ProjectionHandle', + 'WorldlineOptic', + 'Observer', + 'Optic', + 'Patch', + 'PatchBuilder', + 'PatchCommitter', + 'GitWarpBraidHologram', + 'GitWarpBraidHologramFields', + 'GitWarpBraidHologramMember', + 'GitWarpBraidHologramMemberFields', + 'GitWarpSuffixTransformHologram', + 'GitWarpSuffixTransformHologramFields', + 'GitWarpTickHologram', + 'GitWarpTickHologramFields', +]); + +const FORBIDDEN_BROWSER_V19_EXPORTS = Object.freeze([ + 'openWarp', + 'Warp', + 'Timeline', + 'intent', + 'Intent', + 'reading', + 'Reading', + 'ReadReceipt', + 'ReadingResult', + 'WriteReceipt', + 'DraftTimeline', + 'JoinReceipt', + 'JoinResult', + 'OpenWarpOptions', + 'WarpStorage', +]); + function exportedNamesFor(path: string): ReadonlySet { const sourceFile = sourceFileFor(path); const exportedNames = new Set(); @@ -129,11 +178,17 @@ describe('v19 Warp facade', () => { it('keeps the v19 facade off the browser root', () => { const browserExports = exportedNamesFor('browser.ts'); - expect(browserExports.has('openWarp')).toBe(false); - expect(browserExports.has('Warp')).toBe(false); - expect(browserExports.has('Timeline')).toBe(false); - expect(browserExports.has('OpenWarpOptions')).toBe(false); - expect(browserExports.has('WarpStorage')).toBe(false); + for (const name of FORBIDDEN_BROWSER_V19_EXPORTS) { + expect(browserExports.has(name)).toBe(false); + } + }); + + it('keeps substrate graph, worldline, patch, optic, and hologram names off the root', () => { + const rootExports = exportedNamesFor('index.ts'); + + for (const name of FORBIDDEN_ROOT_SUBSTRATE_EXPORTS) { + expect(rootExports.has(name)).toBe(false); + } }); it('keeps internal history vocabulary off the public facade objects', async () => { @@ -214,6 +269,131 @@ describe('v19 Warp facade', () => { })).toThrow('Invalid writer ID: exceeds maximum length'); }); + it('writes public intents and returns accepted write receipts', async () => { + const warp = await openWarp({ + storage: new MemoryStorageAdapter(), + writer: 'agent-1', + }); + const timeline = await warp.timeline('events'); + + const nodeReceipt = await timeline.write(intent.node.add({ subject: 'user:alice' })); + const propertyReceipt = await timeline.write(intent.property.set({ + subject: 'user:alice', + key: 'role', + value: 'admin', + })); + + expect(nodeReceipt.outcome).toBe('accepted'); + expect(nodeReceipt.intent.kind).toBe('node.add'); + expect(typeof nodeReceipt.patchSha).toBe('string'); + expect(propertyReceipt.outcome).toBe('accepted'); + expect(propertyReceipt.intent.kind).toBe('property.set'); + expect(propertyReceipt.timeline).toBe('events'); + expect(propertyReceipt.writer).toBe('agent-1'); + + const result = await requireTimelineRuntime(timeline) + .live() + .query() + .match('user:*') + .select(['id', 'props']) + .run(); + + expect('nodes' in result).toBe(true); + if (!('nodes' in result)) { + return; + } + expect(result.nodes).toEqual([ + { id: 'user:alice', props: { role: 'admin' } }, + ]); + }); + + it('reads public readings and returns resolved read receipts', async () => { + const warp = await openWarp({ + storage: new MemoryStorageAdapter(), + writer: 'agent-1', + }); + const timeline = await warp.timeline('events'); + + await timeline.write(intent.node.add({ subject: 'user:alice' })); + await timeline.write(intent.property.set({ + subject: 'user:alice', + key: 'role', + value: 'admin', + })); + + const propertyResult = await timeline.read(reading.property({ + subject: 'user:alice', + key: 'role', + })); + const existsResult = await timeline.read(reading.node.exists({ + subject: 'user:alice', + })); + + expect(propertyResult).toBeInstanceOf(ReadingResult); + expect(propertyResult.receipt).toBeInstanceOf(ReadReceipt); + expect(propertyResult.value).toBe('admin'); + expect(propertyResult.receipt.outcome).toBe('resolved'); + expect(propertyResult.receipt.reading.kind).toBe('property.get'); + expect(propertyResult.receipt.timeline).toBe('events'); + expect(propertyResult.receipt.writer).toBe('agent-1'); + + expect(existsResult).toBeInstanceOf(ReadingResult); + expect(existsResult.value).toBe(true); + expect(existsResult.receipt.outcome).toBe('resolved'); + expect(existsResult.receipt.reading.kind).toBe('node.exists'); + }); + + it('drafts speculative writes, previews joins, and joins with receipts', async () => { + const warp = await openWarp({ + storage: new MemoryStorageAdapter(), + writer: 'agent-1', + }); + const timeline = await warp.timeline('events'); + + await timeline.write(intent.node.add({ subject: 'user:alice' })); + const draft = await timeline.draft('try-admin-role'); + + const draftWrite = await draft.write(intent.property.set({ + subject: 'user:alice', + key: 'role', + value: 'admin', + })); + const beforeJoin = await timeline.read(reading.property({ + subject: 'user:alice', + key: 'role', + })); + const preview = await timeline.previewJoin(draft, { + policy: 'deterministic', + }); + const afterPreview = await timeline.read(reading.property({ + subject: 'user:alice', + key: 'role', + })); + const joined = await timeline.join(draft); + const afterJoin = await timeline.read(reading.property({ + subject: 'user:alice', + key: 'role', + })); + + expect(draft).toBeInstanceOf(DraftTimeline); + expect(draft.name).toBe('try-admin-role'); + expect(draft.timeline).toBe('events'); + expect(draft.writer).toBe('agent-1'); + expect(draftWrite.outcome).toBe('accepted'); + expect(beforeJoin.value).toBeNull(); + expect(preview).toBeInstanceOf(JoinResult); + expect(preview.receipt).toBeInstanceOf(JoinReceipt); + expect(preview.receipt.mode).toBe('preview'); + expect(preview.receipt.outcome).toBe('accepted'); + expect(preview.receipt.patchShas).toContain(draftWrite.patchSha); + expect(afterPreview.value).toBeNull(); + expect(joined).toBeInstanceOf(JoinResult); + expect(joined.receipt.mode).toBe('join'); + expect(joined.receipt.outcome).toBe('accepted'); + expect(joined.receipt.patchShas).toHaveLength(1); + expect(afterJoin.value).toBe('admin'); + }); + it('names the openWarp identity failure payload once', () => { expect(OPEN_WARP_IDENTITY_FAILURE).toEqual({ message: 'openWarp requires non-empty identity fields', diff --git a/test/unit/scripts/pre-push-hook.test.ts b/test/unit/scripts/pre-push-hook.test.ts index 609c38548..c2a47ca28 100644 --- a/test/unit/scripts/pre-push-hook.test.ts +++ b/test/unit/scripts/pre-push-hook.test.ts @@ -136,7 +136,7 @@ describe('scripts/hooks/pre-push', () => { const result = runPrePushHook({ quick: true }); expect(result.status).toBe(0); - expect(result.events[0]).toBe('linkcheck:--config .lychee.toml **/*.md'); + expect(result.events[0]).toBe('linkcheck:--config .lychee.toml --include-fragments **/*.md'); expect(result.events.slice(1).sort()).toEqual([ 'npm:lint', 'npm:lint:docs-topology', @@ -168,7 +168,7 @@ describe('scripts/hooks/pre-push', () => { 'typecheck:surface', 'typecheck:test', ]); - expect(result.lycheeCalls).toEqual(['--config .lychee.toml **/*.md']); + expect(result.lycheeCalls).toEqual(['--config .lychee.toml --include-fragments **/*.md']); }); it('skips Gate 0 when the launcher target is unavailable', () => {