Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,18 @@ await coordinator.state.set([
]);
```

Every declaration change — an ACL, event or function slice, or a reconnect —
resends the complete state slice (RFC 0001 §7.5). It carries the value the relay
last acknowledged for each path, not the configured one, so revoking one user
never rolls the home back for the others. Only mutations confirmed by
`STATE_SET_OK` are carried, including one whose caller aborted after handoff; a
rejected mutation or one lost with its connection (`outcome_unknown`) is never
replayed, and the last acknowledged value is resent instead. An explicit
`state.declare` or `configure` replaces acknowledged values. `STATE_SYNC` cannot
declare a path without a value, so an acknowledged deletion is staged with the
declared value and then reapplied by one SDK-sent deletion right after the
declaration activates; users may briefly see the declared value in between.

Event publication returns a synchronous opaque ID and a transport-handoff
promise. `sent` is not a delivery receipt; a later correlated relay rejection is
reported through `coordinator.errors`.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "miakapi",
"version": "4.0.0-alpha.0",
"version": "4.0.0-alpha.1",
"description": "Typed coordinator and trusted-browser SDK for Miakapp",
"type": "module",
"packageManager": "bun@1.2.23",
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/test/starter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,12 @@ describe('the generated starter builds, typechecks, checks and runs', () => {
});

test('the generated check uses its local CLI even when PATH contains an older global CLI', async () => {
// This test executes the packaged entry point, so it must build its own
// prerequisite rather than depend on another test having run first.
const cliBuild = Bun.spawnSync(['bun', 'run', 'build'], {
cwd: join(REPOSITORY, 'packages/cli'), stdout: 'pipe', stderr: 'pipe',
});
expect(cliBuild.exitCode).toBe(0);
const shadow = join(directory, 'shadow-bin');
await mkdir(shadow);
await writeFile(join(shadow, 'miakapp'), '#!/bin/sh\necho stale-global-cli >&2\nexit 81\n');
Expand Down
4 changes: 4 additions & 0 deletions src/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,10 @@ class CoordinatorImpl implements
this.calls.setActiveDeclarations(active);
}

restoreDeletions(session: RelaySession, active: ActiveDeclarations, paths: readonly string[]): void {
this.state.restoreDeletions(session, active, paths);
}

declarationsReady(_receipt: DeclarationReceipt): void {
const session = this.#session;
if (session === undefined) return;
Expand Down
161 changes: 159 additions & 2 deletions src/internal/declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
EventDeclaration,
FunctionHandler,
ProtocolValue,
StateMutation,
UserEventAccess,
UserStateAccess,
} from '../api.js';
Expand Down Expand Up @@ -76,6 +77,24 @@ export interface DeclarationHost {
hasQueuedSnapshot: boolean,
): void;
transportFailure(error: unknown): void;
/**
* Activation staged the declared value for paths whose acknowledged state is
* a deletion, which `STATE_SYNC` cannot express; the host reapplies them.
*/
restoreDeletions(session: RelaySession, active: ActiveDeclarations, paths: readonly string[]): void;
}

/**
* The last value the relay acknowledged for one path, through `STATE_SET_OK`.
* `revision` is the state-slice revision the mutation was applied to: the entry
* only ever amends a re-declaration of that same slice, so an explicit
* `state.declare` or `configure` stays authoritative.
*/
interface ConfirmedEntry {
readonly revision: number;
readonly sequence: number;
readonly deleted: boolean;
readonly value: ProtocolValue | undefined;
}

interface PendingDeclaration {
Expand Down Expand Up @@ -137,6 +156,28 @@ function copySnapshot(value: DeclarationSnapshot): DeclarationSnapshot {
});
}

function protocolValuesEqual(left: ProtocolValue | undefined, right: ProtocolValue | undefined): boolean {
if (left === right) return true;
if (left instanceof Uint8Array || right instanceof Uint8Array) {
return left instanceof Uint8Array && right instanceof Uint8Array
&& left.length === right.length && left.every((byte, index) => byte === right[index]);
}
if (Array.isArray(left) || Array.isArray(right)) {
return Array.isArray(left) && Array.isArray(right) && left.length === right.length
&& left.every((item, index) => protocolValuesEqual(item, right[index]));
}
if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') {
return Number.isNaN(left) && Number.isNaN(right);
}
const leftKeys = Object.keys(left);
return leftKeys.length === Object.keys(right).length
&& leftKeys.every((key) => Object.hasOwn(right, key)
&& protocolValuesEqual(
(left as Record<string, ProtocolValue>)[key],
(right as Record<string, ProtocolValue>)[key],
));
}

function revisionsEqual(left: DeclarationRevisions, right: DeclarationRevisions): boolean {
return DECLARATION_DOMAINS.every((domain) => left[domain] === right[domain]);
}
Expand Down Expand Up @@ -189,6 +230,9 @@ export class DeclarationManager {
#current: DeclarationTransaction | undefined;
#transactionToken = 0;
#stopped = false;
readonly #confirmed = new Map<string, ConfirmedEntry>();
/** A confirmed value arrived after the current transaction was handed off. */
#confirmedAfterHandoff = false;

constructor(host: DeclarationHost) {
this.#host = host;
Expand Down Expand Up @@ -275,9 +319,82 @@ export class DeclarationManager {
synchronize(session: RelaySession): void {
this.#requests.clear();
this.#current = undefined;
this.#confirmedAfterHandoff = false;
this.#begin(session);
}

/**
* Records a mutation batch the relay acknowledged with `STATE_SET_OK`.
*
* Every later re-declaration of the same state slice carries these values
* instead of the declared ones, because `STATE_SYNC` replaces the relay's
* values and an ACL, event or function change — or a reconnect — must not
* roll the home back. Only acknowledged batches are recorded: a rejected or
* unknown mutation never reaches this method, so it is never replayed.
*
* `sequence` is the order in which the batches were sent. The relay applies
* one connection's frames in order, so the later batch wins a path.
*/
recordConfirmed(
mutations: readonly StateMutation[],
revision: number,
sequence: number,
): void {
let changedValue = false;
for (const mutation of mutations) {
const existing = this.#confirmed.get(mutation.path);
if (existing !== undefined && existing.revision === revision && existing.sequence > sequence) continue;
const deleted = 'delete' in mutation;
this.#confirmed.set(mutation.path, Object.freeze({
revision,
sequence,
deleted,
value: deleted ? undefined : mutation.value,
}));
const current = this.#current;
if (!deleted && current !== undefined
&& current.snapshot.revisions.state === revision
&& Object.hasOwn(current.snapshot.state, mutation.path)
&& !protocolValuesEqual(current.snapshot.state[mutation.path], mutation.value)) {
changedValue = true;
}
}
const current = this.#current;
if (!changedValue || current === undefined) return;
// The relay applied this batch before it staged the current STATE_SYNC,
// which captured the older value. Activating that stage would roll the
// mutation back while users see the result as current, so the stage is
// replaced: a new STATE_SYNC discards it on the relay (RFC 0001 §7.5).
if (!current.handedOff) {
this.#current = undefined;
this.#begin(current.session);
} else {
this.#confirmedAfterHandoff = true;
}
}

/**
* The relay refused to reapply an acknowledged deletion, so it holds the
* declared value again. That value becomes the acknowledged one, unless a
* newer mutation of the path was acknowledged meanwhile.
*/
restorationRefused(paths: ReadonlyMap<string, number>): void {
for (const [path, sequence] of paths) {
const entry = this.#confirmed.get(path);
if (entry?.deleted === true && entry.sequence === sequence) this.#confirmed.delete(path);
}
}

/** The confirmed entry sequence of each path, for a restoration in flight. */
confirmedSequences(paths: readonly string[]): Map<string, number> {
const result = new Map<string, number>();
for (const path of paths) {
const entry = this.#confirmed.get(path);
if (entry !== undefined) result.set(path, entry.sequence);
}
return result;
}

handleFrame(frame: Frame): boolean {
const domain = ackDomain(frame.opcode);
if (domain === undefined) return false;
Expand Down Expand Up @@ -407,7 +524,7 @@ export class DeclarationManager {
const welcome = session.welcome;
this.#current = {
token: this.#transactionToken,
snapshot: copySnapshot(this.#desired),
snapshot: this.#withConfirmedState(copySnapshot(this.#desired)),
session,
receipt: Object.freeze({
sessionId: welcome.readySession.sessionId,
Expand Down Expand Up @@ -479,6 +596,40 @@ export class DeclarationManager {
};
}

/**
* The snapshot to send: the desired slices, with each declared path carrying
* the value the relay last acknowledged for that same state revision.
*/
#withConfirmedState(snapshot: DeclarationSnapshot): DeclarationSnapshot {
let state: Record<string, ProtocolValue> | undefined;
for (const [path, entry] of this.#confirmed) {
if (entry.deleted
|| entry.revision !== snapshot.revisions.state
|| !Object.hasOwn(snapshot.state, path)) continue;
state ??= { ...snapshot.state };
state[path] = entry.value as ProtocolValue;
}
return state === undefined
? snapshot
: copySnapshot({ ...snapshot, state: Object.freeze(state) });
}

/**
* After activation, forgets what the activated declaration superseded and
* returns the acknowledged deletions the activation undid.
*/
#settleConfirmedState(snapshot: DeclarationSnapshot): string[] {
const deletions: string[] = [];
for (const [path, entry] of this.#confirmed) {
if (entry.revision !== snapshot.revisions.state || !Object.hasOwn(snapshot.state, path)) {
this.#confirmed.delete(path);
} else if (entry.deleted) {
deletions.push(path);
}
}
return deletions;
}

#activate(transaction: DeclarationTransaction): void {
if (this.#current?.token !== transaction.token) return;
this.#active = Object.freeze({
Expand All @@ -490,8 +641,12 @@ export class DeclarationManager {
});
this.#current = undefined;
this.#host.activeDeclarationsChanged(this.#active);
const deletions = this.#settleConfirmedState(transaction.snapshot);
if (deletions.length > 0) this.#host.restoreDeletions(transaction.session, this.#active, deletions);
this.#settleThrough(transaction.snapshot.revisions, transaction.receipt, undefined);
if (!revisionsEqual(this.#desired.revisions, transaction.snapshot.revisions)) {
const refresh = this.#confirmedAfterHandoff;
this.#confirmedAfterHandoff = false;
if (refresh || !revisionsEqual(this.#desired.revisions, transaction.snapshot.revisions)) {
this.#begin(transaction.session);
} else {
this.#host.declarationsReady(transaction.receipt);
Expand All @@ -501,6 +656,8 @@ export class DeclarationManager {
#rejectTransaction(transaction: DeclarationTransaction, failure: CoordinatorError): void {
if (this.#current?.token !== transaction.token) return;
this.#current = undefined;
// Nothing activated, so the relay still holds every acknowledged value.
this.#confirmedAfterHandoff = false;
this.#settleThrough(transaction.snapshot.revisions, transaction.receipt, failure);
const activeSnapshot = this.#active?.snapshot ?? emptySnapshot();
const desired = this.#desired;
Expand Down
Loading
Loading