Bring the TypeScript client closer to parity with the .NET client - #59
Merged
Conversation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
The generator wrote a compliance array through a (schema as Record<string, unknown>) cast because JsonSchema had no field for it. Add ComplianceSchemaMetadata and a typed compliance property so the cast can be dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Mirrors Cratis.Chronicle.Schemas.JsonSchemaGenerator (the C# gold standard) in three ways addComplianceMetadata did not: - A class-level @pii() only fed getMetadataForType() with the property's own type, never with the declaring class, so "@pii() class Foo { a = ''; b = ''; }" produced a schema with zero compliance entries and every field was persisted in the clear. collectComplianceMetadata() now also consults target.constructor. - Compliance metadata was written onto whatever node addComplianceMetadata was given, including an object node. Chronicle then stores one ciphertext string where the schema says "object", and release hands the read model a string instead of an object. addComplianceMetadataToSchema now descends into schema.properties and lands metadata on the leaves, de-duplicating by metadata type the way HasMetadataOfType does in C#. - An array whose element is a ConceptAs<T> lost its classification: mapRuntimeTypeToSchema() always emitted a bare `{type: 'object'}` item schema. It now resolves the element type from a `@field(Array, { genericArguments: [...] })` declaration and carries the element concept's own compliance metadata onto `items`, mirroring C#'s explicit enumerable-of-concept branch (including leaving the array container itself uncoerced into compliance, since coarse collection-level encryption is a separate, established behavior). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Covers class-level @pii() marking every property, property-level @pii(), concept-typed @pii() on both an event and a read model, class-level combined with property-level producing no duplicate compliance entries, a property-level @pii() on a nested composite value object descending to its leaves, and an array whose element is a PII concept carrying its classification onto the item schema without leaving coarse compliance on the array container. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
C# throws PIINotSupportedOnEventSourceId when [PII] is applied to a type deriving from EventSourceId/EventSourceId<T>, because encrypting the event source identifier makes its own decryption key unfindable. TypeScript has no equivalent identity type - the event source identifier is always the conventional 'eventSourceId' property (see EventForEventSourceId, EventContext) - so guard that property instead: pii() now throws PIINotSupportedOnEventSourceId when applied to a property named 'eventSourceId'. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
C# IPIIManager has AllowNewEncryptionKeyFor(EncryptionKeyIdentifier) alongside DeleteEncryptionKeyFor, to re-authorize a subject's key after a GDPR erasure. The TypeScript IPIIManager only had deleteEncryptionKey. The AllowNewEncryptionKey RPC already exists on the wire (verified against @cratis/chronicle.contracts 17.0.0's generated compliance.ts), so add the request/codec to the hand-rolled ComplianceContracts.ts alongside the existing DeleteEncryptionKeyRequest, and implement allowNewEncryptionKeyFor on PIIManager. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Fills the 16 compliance/** snippet IDs the shared Chronicle docs reference but TypeScript resolved to nothing: pii/import, pii/event-source-id-restriction, pii/value-object-class, pii/nested-value-object, pii-with-concepts/why-concept-level, pii-with-concepts/event-source-id-restriction, pii-with-concepts/surrogate-key, client/concept-usage, client/combining, client/event-source-id-restriction, client/registering-compliance, erasure/delete-key, erasure/allow-new-key, read-models/projection-lineage, read-models/reducer-explicit-pii, read-models/querying. Each is the idiomatic TypeScript equivalent of the C# snippet it mirrors. Two areas have no direct TypeScript equivalent and are written accordingly rather than faked: the event-source-id-restriction snippets use the conventional 'eventSourceId' property (TypeScript has no EventSourceId<T> type to attach the restriction to), and registering-compliance shows that no DI-container step exists - the PII manager is available as soon as an event store is. All snippets pass validate-client-snippets.py, which compiles them for real against the built client. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
…nctions, joins, from-every, event-context) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gNGagYJgxyTSwGg5HmNVR
This was referenced Sep 1, 2026
Mirrors the .NET client's SubjectAttribute: a property decorator that marks the natural person a read model's PII belongs to, so a future resolver can select the correct encryption key instead of assuming a hardcoded 'id' property.
Given a read model type and instance, resolves the compliance subject by checking the @subject()-decorated property first and falling back to the conventional 'id' property, mirroring the .NET client's ReadModelSubjectResolver.
ReadModels.release/releaseMany used to throw unless a read model had a property literally named 'id'. MaterializedReadModels silently skipped release the same way. Both now defer to ReadModelSubjectResolver, which checks a @subject()-decorated property before falling back to 'id', so a read model whose GDPR subject isn't named 'id' releases against the correct encryption key. The 'id' fallback keeps existing read models working unchanged.
The TypeScript snippet for IReadModels.Release() used to release against the ticket's own id, unlike the C# tab which marks CustomerId with [Subject] to release against the customer's key instead. Bring the TypeScript example to parity now that @subject() exists.
TypeScript's IProjections exposed only discover() and register(), leaving no way to inspect or operate on a projection once it was running. Add the state/replay/failed-partitions/query surface that .NET's IProjections already exposes, backed by the real wire RPCs: - hasFor/hasForModel/getProjectionIdFor - resolve a discovered projection's identifier, locally from what discover() found. - getStateFor/getStateForModel - Observers.GetObserverInformation. - getFailedPartitionsForModel - reuses the existing FailedPartitions wrapper, resolving the projection identifier first. - replay/replayForModel - Observers.Replay. - query - Projections.Preview, throwing UnableToQueryProjection on a PDL parsing error. Also fixes ObserverRunningState, whose values never matched the wire enum (Suspended/Quarantined had no representation at all) and had no consumer anywhere in the codebase - getStateFor is the first. Add the eventSequence()/eventLog() model-bound decorators so getStateFor(Model) can resolve a projection's real event sequence instead of assuming event-log; resolveModelBoundMetadata() picking this up for registration follows in the next commit.
…arWith Closes four model-bound gaps against .NET's model-bound attributes: - @Fromall - .NET's FromAllAttribute; TypeScript only had @fromEvery. Model-bound feeds both into the same All-properties map, so fromAll is wired the same way. - @noAutoMap - class and property level, matching NoAutoMapAttribute. buildModelBoundDefinition() hardcoded AutoMap.Enabled and never populated NoAutoMapProperties, so AutoMap could not be disabled and a colliding event property could silently overwrite an explicitly sourced value. - @eventSequence/@eventlog - resolveModelBoundMetadata() always returned eventSequenceId: undefined, pinning every model-bound projection to the event log regardless of what the model declared. - Root-level @clearWith on a scalar property - previously only consumed inside childrenAndNestedBuilder.ts for child/nested types. A root scalar clearWith now adds a $null mapping to that property's From entry, clearing it back to no value whenever the event occurs.
Replace the "does not support this workflow yet" placeholders with real snippets for the model-bound and PDL-query workflows landed in the previous two commits: noAutoMap (class and property), fromAll, eventSequence/eventLog, root-level clearWith, and query(). Register the five adhoc-querying snippets in validate-client-snippets.py's BODY_SNIPPETS so they get the store parameter they call store.projections.query() through, matching how every other store-calling snippet is wired into the generated compilation unit. Every other workflow's fence is left in place - only what actually works now is un-fenced.
Tag concept, @tag/@tags decorators, @filterEventsByTag for reactors and reducers, tags on AppendOptions and EventContext, and tags in seeding. The wire fields were already being sent as empty arrays. Specs are not yet written for this surface.
Covers tag merge semantics, the tag/tags/filterEventsByTag decorators, and that both reach a reducer registration where Tags and FilterTags were previously hardcoded empty.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Brings the TypeScript client closer to the .NET client on compliance, projections and event tagging.
Real coverage of the 759 shared documentation snippet IDs: 256 → 452 (33.7% → 59.6%).
Breaking
@pii()on a property namedeventSourceIdnow throwsPIINotSupportedOnEventSourceId. TypeScript has noEventSourceIdtype to guard on, so the check is on the conventional property name. Encrypting the event source identifier makes its own decryption key unfindable, which is unrecoverable (Let's get all the clients on par with the latest changes in the base .NET/C# client in the Chronicle repository. Get ... #58)ObserverRunningStatehad members (Subscribing,Resuming,Paused,Stopped,Failed) that never existed on the wire. It now matches the contract:Unknown,Active,Suspended,Replaying,Disconnected,Quarantined(Let's get all the clients on par with the latest changes in the base .NET/C# client in the Chronicle repository. Get ... #58)Added
@tag()/@tags()on an event type, reactor or reducer,@filterEventsByTag()to restrict which events reach an observer, tags onAppendOptionsandEventContext, and tags in seeding. The wire fields were already being sent as empty arrays (Let's get all the clients on par with the latest changes in the base .NET/C# client in the Chronicle repository. Get ... #58)@subject()— the property a compliance release decrypts PII against, with a resolver that falls back toidexactly as before when the decorator is absent (Let's get all the clients on par with the latest changes in the base .NET/C# client in the Chronicle repository. Get ... #58)IProjections—getStateFor/getStateForModel,replay/replayForModel,getFailedPartitionsForModel,hasFor/hasForModel,getProjectionIdFor, andqueryfor ad-hoc projection declarations. It previously had onlydiscoverandregister, so nothing could inspect or operate a projection (Let's get all the clients on par with the latest changes in the base .NET/C# client in the Chronicle repository. Get ... #58)@fromAll,@noAutoMap, and@eventSequence/@eventLogfor model-bound projections; root-level@clearWithis now honored (Let's get all the clients on par with the latest changes in the base .NET/C# client in the Chronicle repository. Get ... #58)IPIIManager.allowNewEncryptionKeyFor— re-authorize an encryption key for a subject after erasure (Let's get all the clients on par with the latest changes in the base .NET/C# client in the Chronicle repository. Get ... #58)Changed
complianceis a declared property on theJsonSchematype rather than written through a cast (Let's get all the clients on par with the latest changes in the base .NET/C# client in the Chronicle repository. Get ... #58)Fixed
@pii()did not mark the class's own properties. A@pii()-adorned read model or event produced a schema with no compliance entries at all, so values the .NET client would encrypt were written in the clear (Let's get all the clients on par with the latest changes in the base .NET/C# client in the Chronicle repository. Get ... #58)@pii()composite value object stored one opaque ciphertext string where the schema still saidobject— releasing it returned a string and the read model failed to materialize. Metadata now lands on leaves and is de-duplicated (Let's get all the clients on par with the latest changes in the base .NET/C# client in the Chronicle repository. Get ... #58)@pii()-marked concepts lost its classification entirely — every array was emitted with its element type discarded (Let's get all the clients on par with the latest changes in the base .NET/C# client in the Chronicle repository. Get ... #58)event-logand always had AutoMap enabled; neither could be changed (Let's get all the clients on par with the latest changes in the base .NET/C# client in the Chronicle repository. Get ... #58)idlookup that threw when absent, so a read model whose subject is not itsidreleased against the wrong encryption key (Let's get all the clients on par with the latest changes in the base .NET/C# client in the Chronicle repository. Get ... #58)