chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
97
third_party/pi/packages/durable/src/session/forks.ts
vendored
Normal file
97
third_party/pi/packages/durable/src/session/forks.ts
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import { addressId } from "../documents.ts";
|
||||
import type {
|
||||
ConversationId,
|
||||
Cursor,
|
||||
DocumentCopySource,
|
||||
DocumentCreate,
|
||||
DocumentId,
|
||||
DocumentPoint,
|
||||
DocumentRecord,
|
||||
EntryId,
|
||||
Storage,
|
||||
} from "../types.ts";
|
||||
|
||||
const SCAN_PAGE_SIZE = 256;
|
||||
|
||||
type ForkPolicy = "asOf" | "current";
|
||||
|
||||
/** One definition-free document copy to create with a forked conversation. */
|
||||
export type ForkDocumentCopy = {
|
||||
readonly record: DocumentCreate;
|
||||
readonly source: DocumentCopySource;
|
||||
};
|
||||
|
||||
/** Select every persisted conversation document copied by one fork. */
|
||||
export async function prepareForkDocumentCopies(
|
||||
storage: Storage,
|
||||
parentConversationId: ConversationId,
|
||||
at: EntryId,
|
||||
childConversationId: ConversationId,
|
||||
context: Context,
|
||||
): Promise<readonly ForkDocumentCopy[]> {
|
||||
const entry = await storage.entry(parentConversationId, at, context);
|
||||
if (entry === undefined) throw new Error(`Entry ${at} is not visible from conversation ${parentConversationId}`);
|
||||
|
||||
const copies: ForkDocumentCopy[] = [];
|
||||
const copiedAddresses = new Set<string>();
|
||||
await collectCopies(
|
||||
storage,
|
||||
{ kind: "conversation", conversationId: entry.entry.conversationId },
|
||||
entry.commitSeq,
|
||||
"asOf",
|
||||
childConversationId,
|
||||
copies,
|
||||
copiedAddresses,
|
||||
context,
|
||||
);
|
||||
await collectCopies(
|
||||
storage,
|
||||
{ kind: "conversation", conversationId: parentConversationId },
|
||||
"current",
|
||||
"current",
|
||||
childConversationId,
|
||||
copies,
|
||||
copiedAddresses,
|
||||
context,
|
||||
);
|
||||
return copies;
|
||||
}
|
||||
|
||||
async function collectCopies(
|
||||
storage: Storage,
|
||||
scope: Extract<DocumentRecord["scope"], { readonly kind: "conversation" }>,
|
||||
at: DocumentPoint,
|
||||
policy: ForkPolicy,
|
||||
childConversationId: ConversationId,
|
||||
copies: ForkDocumentCopy[],
|
||||
copiedAddresses: Set<string>,
|
||||
context: Context,
|
||||
): Promise<void> {
|
||||
let cursor: Cursor | undefined;
|
||||
do {
|
||||
const page = await storage.scanDocuments({ scope, at }, SCAN_PAGE_SIZE, cursor, context);
|
||||
for (const source of page.items) {
|
||||
if (source.scope.kind !== "conversation" || source.fork !== policy) continue;
|
||||
const id = await storage.mintId<DocumentId>();
|
||||
const identity = {
|
||||
id,
|
||||
kind: source.kind,
|
||||
...(source.key === undefined ? {} : { key: source.key }),
|
||||
scope: { kind: "conversation" as const, conversationId: childConversationId },
|
||||
};
|
||||
const record: DocumentCreate =
|
||||
source.history === "latest"
|
||||
? { ...identity, history: "latest", fork: source.fork }
|
||||
: { ...identity, history: "rewindable", fork: source.fork };
|
||||
const copyAddress = addressId(record);
|
||||
if (copiedAddresses.has(copyAddress)) {
|
||||
const member = record.key === undefined ? record.kind : `${record.kind}/${record.key}`;
|
||||
throw new Error(`Fork selects multiple source documents for ${member}`);
|
||||
}
|
||||
copiedAddresses.add(copyAddress);
|
||||
copies.push({ record, source: { id: source.id, at } });
|
||||
}
|
||||
cursor = page.next;
|
||||
} while (cursor !== undefined);
|
||||
}
|
||||
292
third_party/pi/packages/durable/src/session/observation.ts
vendored
Normal file
292
third_party/pi/packages/durable/src/session/observation.ts
vendored
Normal file
@@ -0,0 +1,292 @@
|
||||
import type {
|
||||
Context,
|
||||
JsonValue,
|
||||
ReplicatedStateSource,
|
||||
ReplicatedStateSourceAttachment,
|
||||
ReplicatedStateSourceFrame,
|
||||
} from "@earendil-works/chord";
|
||||
import { withoutAbortSignal } from "@earendil-works/chord/context";
|
||||
import type { Op } from "@earendil-works/chord/delta";
|
||||
import { errorMessage } from "../errors.ts";
|
||||
import type { JsonObject, WatchEnd, WatchHandle } from "../types.ts";
|
||||
|
||||
export type ObservedDocumentValue = Readonly<JsonObject> | null;
|
||||
|
||||
/** Maximum exact committed frames retained behind one unavailable watch listener. */
|
||||
const MAX_PENDING_WATCH_FRAMES = 100;
|
||||
|
||||
/** Canonical terminal update for a retired document incarnation. */
|
||||
export const RETIREMENT_OPERATIONS: readonly Op[] = [["r", null]];
|
||||
|
||||
/**
|
||||
* Session-to-Chord bridge owned one-to-one by one attached state: a document, or a conversation view. A `null` value
|
||||
* retires it. @internal
|
||||
*/
|
||||
export class CommittedStateSource<T = ObservedDocumentValue> implements ReplicatedStateSource<T> {
|
||||
readonly #attachments = new Set<SessionSourceAttachment<T>>();
|
||||
#release: (() => void) | undefined;
|
||||
/** Released on disposal. */
|
||||
#value: T | undefined;
|
||||
#cursor = 0;
|
||||
#retired = false;
|
||||
#closed = false;
|
||||
|
||||
constructor(value: T, release: () => void) {
|
||||
this.#value = value;
|
||||
this.#release = release;
|
||||
}
|
||||
|
||||
attach(): ReplicatedStateSourceAttachment<T> {
|
||||
if (this.#closed) throw new Error("State source is closed");
|
||||
const attachment = new SessionSourceAttachment({ value: this.#value as T, cursor: this.#cursor }, () => {
|
||||
this.#attachments.delete(attachment);
|
||||
if (this.#attachments.size === 0) this.#finishDisposal();
|
||||
});
|
||||
this.#attachments.add(attachment);
|
||||
return attachment;
|
||||
}
|
||||
|
||||
advance(value: T, ops: readonly Op[], context: Context): void {
|
||||
if (this.#closed || this.#retired) return;
|
||||
this.#value = value;
|
||||
this.#cursor += 1;
|
||||
if (value === null) this.#retired = true;
|
||||
const frame: ReplicatedStateSourceFrame<T> = {
|
||||
cursor: this.#cursor,
|
||||
value,
|
||||
ops,
|
||||
context,
|
||||
};
|
||||
for (const attachment of [...this.#attachments]) attachment.publish(frame);
|
||||
}
|
||||
|
||||
/** Stop publishing; the attached states keep their last value. A failure ends them the same way. */
|
||||
closeSession(): void {
|
||||
if (this.#closed) return;
|
||||
for (const attachment of [...this.#attachments]) attachment.dispose();
|
||||
this.#finishDisposal();
|
||||
}
|
||||
|
||||
#finishDisposal(): void {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#value = undefined;
|
||||
const release = this.#release;
|
||||
this.#release = undefined;
|
||||
release?.();
|
||||
}
|
||||
}
|
||||
|
||||
class SessionSourceAttachment<T> implements ReplicatedStateSourceAttachment<T> {
|
||||
readonly snapshot: { readonly value: T; readonly cursor: number };
|
||||
#release: (() => void) | undefined;
|
||||
readonly #frames: ReplicatedStateSourceFrame<T>[] = [];
|
||||
#listener: ((frame: ReplicatedStateSourceFrame<T>) => void) | undefined;
|
||||
#activated = false;
|
||||
#disposed = false;
|
||||
#scheduled = false;
|
||||
#delivering = false;
|
||||
|
||||
constructor(snapshot: { readonly value: T; readonly cursor: number }, release: () => void) {
|
||||
this.snapshot = snapshot;
|
||||
this.#release = release;
|
||||
}
|
||||
|
||||
activate(listener: (frame: ReplicatedStateSourceFrame<T>) => void): void {
|
||||
if (this.#activated) throw new Error("State attachment is already active");
|
||||
if (this.#disposed) throw new Error("State attachment is disposed");
|
||||
this.#activated = true;
|
||||
this.#listener = listener;
|
||||
this.#drain();
|
||||
}
|
||||
|
||||
publish(frame: ReplicatedStateSourceFrame<T>): void {
|
||||
if (this.#disposed) return;
|
||||
this.#frames.push(frame);
|
||||
if (!this.#activated || this.#delivering || this.#scheduled) return;
|
||||
this.#scheduled = true;
|
||||
queueMicrotask(() => {
|
||||
this.#scheduled = false;
|
||||
if (this.#disposed) return;
|
||||
try {
|
||||
this.#drain();
|
||||
} catch {
|
||||
// Chord's frame listener contains source-contract failures. Isolate an
|
||||
// unexpected direct listener failure to this attachment as well.
|
||||
this.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
this.#frames.length = 0;
|
||||
this.#listener = undefined;
|
||||
const release = this.#release;
|
||||
this.#release = undefined;
|
||||
release?.();
|
||||
}
|
||||
|
||||
#drain(): void {
|
||||
const listener = this.#listener;
|
||||
if (listener === undefined || this.#delivering || this.#disposed) return;
|
||||
this.#delivering = true;
|
||||
try {
|
||||
while (!this.#disposed) {
|
||||
const frame = this.#frames.shift();
|
||||
if (frame === undefined) break;
|
||||
listener(frame);
|
||||
}
|
||||
} finally {
|
||||
this.#delivering = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type WatchFrame<T> = {
|
||||
readonly value: T;
|
||||
readonly ops: readonly Op[];
|
||||
readonly context: Context;
|
||||
};
|
||||
|
||||
/**
|
||||
* Serialized exact-frame watch bound to one document incarnation or conversation view. A `null` value retires it.
|
||||
* @internal
|
||||
*/
|
||||
export class CommittedWatch<T = ObservedDocumentValue> implements WatchHandle<T> {
|
||||
readonly #detach: () => void;
|
||||
readonly #report: (error: unknown) => void;
|
||||
readonly #replace: (() => T) | undefined;
|
||||
readonly #closed = Promise.withResolvers<WatchEnd>();
|
||||
readonly #pending: WatchFrame<T>[] = [];
|
||||
#value: T;
|
||||
#listener: ((value: T, ops: readonly Op[], context: Context) => Promise<void>) | undefined;
|
||||
#started = false;
|
||||
#scheduled = false;
|
||||
#running = false;
|
||||
#retired = false;
|
||||
/** Set once, by `#terminate()`. */
|
||||
#end: WatchEnd | undefined;
|
||||
#cancellationSignal: AbortSignal | undefined;
|
||||
#cancellationListener: (() => void) | undefined;
|
||||
|
||||
/**
|
||||
* `report` gets a listener's throw, which also ends the watch. `replace` gives the value an overflow delivers; by
|
||||
* default the newest value.
|
||||
*/
|
||||
constructor(value: T, detach: () => void, report: (error: unknown) => void, replace?: () => T) {
|
||||
this.#value = value;
|
||||
this.#detach = detach;
|
||||
this.#report = report;
|
||||
this.#replace = replace;
|
||||
}
|
||||
|
||||
get value(): T {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
get closed(): Promise<WatchEnd> {
|
||||
return this.#closed.promise;
|
||||
}
|
||||
|
||||
start(listener: (value: T, ops: readonly Op[], context: Context) => Promise<void>): void {
|
||||
if (this.#started) throw new Error("Watch is already started");
|
||||
if (this.#end !== undefined) throw new Error("Watch is stopped");
|
||||
this.#started = true;
|
||||
this.#listener = listener;
|
||||
if (this.#pending.length > 0) this.#schedule();
|
||||
}
|
||||
|
||||
stop(): Promise<WatchEnd> {
|
||||
this.#terminate({ reason: "stopped" });
|
||||
return this.#closed.promise;
|
||||
}
|
||||
|
||||
observeCancellation(signal: AbortSignal): void {
|
||||
if (this.#cancellationSignal !== undefined) throw new Error("Watch cancellation is already installed");
|
||||
if (this.#end !== undefined) return;
|
||||
this.#cancellationSignal = signal;
|
||||
this.#cancellationListener = () => this.cancel();
|
||||
signal.addEventListener("abort", this.#cancellationListener, { once: true });
|
||||
if (signal.aborted) this.cancel();
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.#terminate({ reason: "cancelled" });
|
||||
}
|
||||
|
||||
/** End with `session_closed`, or with `session_failed` and the error when the Session failed. */
|
||||
closeSession(failure?: { readonly error: unknown }): void {
|
||||
this.#terminate(
|
||||
failure === undefined ? { reason: "session_closed" } : { reason: "session_failed", error: failure.error },
|
||||
);
|
||||
}
|
||||
|
||||
advance(value: T, ops: readonly Op[], context: Context): void {
|
||||
if (this.#end !== undefined || this.#retired) return;
|
||||
if (value === null) this.#retired = true;
|
||||
if (this.#pending.length >= MAX_PENDING_WATCH_FRAMES) {
|
||||
this.#pending.length = 0;
|
||||
const replacement = this.#replace?.() ?? value;
|
||||
this.#pending.push({ value: replacement, ops: [["r", replacement as JsonValue]], context });
|
||||
} else {
|
||||
this.#pending.push({ value, ops, context });
|
||||
}
|
||||
if (this.#started) this.#schedule();
|
||||
}
|
||||
|
||||
#schedule(): void {
|
||||
if (this.#scheduled || this.#running || this.#end !== undefined) return;
|
||||
this.#scheduled = true;
|
||||
queueMicrotask(() => {
|
||||
this.#scheduled = false;
|
||||
void this.#drain();
|
||||
});
|
||||
}
|
||||
|
||||
async #drain(): Promise<void> {
|
||||
if (this.#running || this.#end !== undefined || !this.#started) return;
|
||||
this.#running = true;
|
||||
try {
|
||||
while (this.#end === undefined) {
|
||||
const frame = this.#pending.shift();
|
||||
if (frame === undefined) break;
|
||||
this.#value = frame.value;
|
||||
const deliveryContext = withoutAbortSignal(frame.context);
|
||||
try {
|
||||
await this.#listener!(frame.value, frame.ops, deliveryContext);
|
||||
} catch (error) {
|
||||
if (this.#end === undefined) {
|
||||
this.#report(error);
|
||||
this.#terminate({ reason: "listener_error", error: toError(error) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (frame.value === null) {
|
||||
this.#terminate({ reason: "retired" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.#running = false;
|
||||
if (this.#end === undefined && this.#pending.length > 0) this.#schedule();
|
||||
}
|
||||
}
|
||||
|
||||
/** End the watch, once: detach, drop pending frames, and settle `closed`. A callback already running keeps running. */
|
||||
#terminate(end: WatchEnd): void {
|
||||
if (this.#end !== undefined) return;
|
||||
this.#end = end;
|
||||
this.#detach();
|
||||
this.#pending.length = 0;
|
||||
if (this.#cancellationListener !== undefined) {
|
||||
this.#cancellationSignal?.removeEventListener("abort", this.#cancellationListener);
|
||||
}
|
||||
this.#closed.resolve(end);
|
||||
}
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(errorMessage(error));
|
||||
}
|
||||
1081
third_party/pi/packages/durable/src/session/session.ts
vendored
Normal file
1081
third_party/pi/packages/durable/src/session/session.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1043
third_party/pi/packages/durable/src/session/transaction.ts
vendored
Normal file
1043
third_party/pi/packages/durable/src/session/transaction.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user