Skip to content

Bring the TypeScript client closer to parity with the .NET client - #59

Merged
einari merged 43 commits into
mainfrom
parity/full-client-parity
Sep 1, 2026
Merged

Bring the TypeScript client closer to parity with the .NET client#59
einari merged 43 commits into
mainfrom
parity/full-client-parity

Conversation

@einari

@einari einari commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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

Added

Changed

Fixed

einari and others added 30 commits September 1, 2026 11:12
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
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.
@einari einari changed the title Fix class-level @pii() leaving values unencrypted, and close doc gaps Bring the TypeScript client closer to parity with the .NET client Sep 1, 2026
@einari
einari merged commit bed0b86 into main Sep 1, 2026
4 checks passed
@einari
einari deleted the parity/full-client-parity branch September 1, 2026 18:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant