@eleven-am/golem-client 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +207 -2
- package/dist/analysis.d.ts +40 -0
- package/dist/analysis.js +471 -0
- package/dist/client.d.ts +49 -0
- package/dist/client.js +77 -0
- package/dist/coalescer.d.ts +14 -0
- package/dist/coalescer.js +37 -0
- package/dist/errors.d.ts +84 -0
- package/dist/errors.js +143 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/live.d.ts +112 -0
- package/dist/live.js +583 -0
- package/dist/manifest.d.ts +26 -0
- package/dist/manifest.js +1 -0
- package/dist/merge.d.ts +41 -0
- package/dist/merge.js +62 -0
- package/dist/mutations.d.ts +20 -0
- package/dist/mutations.js +42 -0
- package/dist/pagination.d.ts +32 -0
- package/dist/pagination.js +133 -0
- package/dist/patcher.d.ts +26 -0
- package/dist/patcher.js +186 -0
- package/dist/protocol.d.ts +56 -0
- package/dist/protocol.js +109 -0
- package/dist/session.d.ts +61 -0
- package/dist/session.js +188 -0
- package/dist/transport.d.ts +27 -0
- package/dist/transport.js +62 -0
- package/package.json +48 -4
package/dist/merge.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { GolemProtocolError } from './errors.js';
|
|
2
|
+
export const EMPTY_MERGE_STATE = { markers: [], changes: [] };
|
|
3
|
+
const DIGITS = /^[0-9]+$/;
|
|
4
|
+
export function parseVersion(raw) {
|
|
5
|
+
if (!DIGITS.test(raw)) {
|
|
6
|
+
throw new GolemProtocolError(`row version is not an unsigned integer: ${JSON.stringify(raw)}`);
|
|
7
|
+
}
|
|
8
|
+
return BigInt(raw);
|
|
9
|
+
}
|
|
10
|
+
export function decideUpdate(cached, event) {
|
|
11
|
+
if (cached === undefined) {
|
|
12
|
+
return 'invalidate';
|
|
13
|
+
}
|
|
14
|
+
return event > cached ? 'patch' : 'ignore';
|
|
15
|
+
}
|
|
16
|
+
export function noteChange(state, key, receivedAt) {
|
|
17
|
+
return { markers: state.markers, changes: [...state.changes, { key, receivedAt }] };
|
|
18
|
+
}
|
|
19
|
+
export function applyRemoval(state, removal, cached) {
|
|
20
|
+
const marker = { key: removal.key, version: removal.version, receivedAt: removal.receivedAt };
|
|
21
|
+
return {
|
|
22
|
+
remove: cached !== undefined && cached <= removal.version,
|
|
23
|
+
state: { markers: [...state.markers, marker], changes: state.changes },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function resolveResponse(state, response) {
|
|
27
|
+
const rows = new Map();
|
|
28
|
+
for (const [key, version] of response.rows) {
|
|
29
|
+
rows.set(key, decideRow(state, response, key, version));
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
rows,
|
|
33
|
+
refetch: droppedChangedKey(state, response),
|
|
34
|
+
state: {
|
|
35
|
+
markers: state.markers.filter((marker) => marker.receivedAt >= response.issuedAt),
|
|
36
|
+
changes: state.changes.filter((change) => change.receivedAt >= response.issuedAt),
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function decideRow(state, response, key, version) {
|
|
41
|
+
for (const marker of state.markers) {
|
|
42
|
+
if (marker.key === key && marker.receivedAt >= response.issuedAt && version <= marker.version) {
|
|
43
|
+
return 'drop';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const cached = response.cached.get(key);
|
|
47
|
+
return cached !== undefined && cached > version ? 'keep-cached' : 'apply';
|
|
48
|
+
}
|
|
49
|
+
function droppedChangedKey(state, response) {
|
|
50
|
+
for (const change of state.changes) {
|
|
51
|
+
if (change.receivedAt < response.issuedAt || response.rows.has(change.key)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!removalExplains(state, change)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
function removalExplains(state, change) {
|
|
61
|
+
return state.markers.some((marker) => marker.key === change.key && marker.receivedAt >= change.receivedAt);
|
|
62
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DocumentNode } from 'graphql';
|
|
2
|
+
import type { QueryClient } from '@tanstack/query-core';
|
|
3
|
+
import type { ClientManifest } from './manifest.js';
|
|
4
|
+
import type { ExecuteRequest } from './transport.js';
|
|
5
|
+
export interface MutationTransport {
|
|
6
|
+
execute<T = unknown>(request: ExecuteRequest): Promise<T>;
|
|
7
|
+
}
|
|
8
|
+
export interface MutationsOptions {
|
|
9
|
+
readonly queryClient: QueryClient;
|
|
10
|
+
readonly transport: MutationTransport;
|
|
11
|
+
readonly manifest: ClientManifest;
|
|
12
|
+
}
|
|
13
|
+
export declare class Mutations {
|
|
14
|
+
private readonly client;
|
|
15
|
+
private readonly transport;
|
|
16
|
+
private readonly manifest;
|
|
17
|
+
constructor(options: MutationsOptions);
|
|
18
|
+
execute<T = unknown>(document: DocumentNode | string, variables?: Readonly<Record<string, unknown>>, signal?: AbortSignal): Promise<T>;
|
|
19
|
+
private invalidateCommitted;
|
|
20
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { analyzeMutation } from './analysis.js';
|
|
2
|
+
import { GolemCommitUnknownError, GolemPartialCommitError } from './errors.js';
|
|
3
|
+
import { readMeta } from './live.js';
|
|
4
|
+
export class Mutations {
|
|
5
|
+
client;
|
|
6
|
+
transport;
|
|
7
|
+
manifest;
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this.client = options.queryClient;
|
|
10
|
+
this.transport = options.transport;
|
|
11
|
+
this.manifest = options.manifest;
|
|
12
|
+
}
|
|
13
|
+
async execute(document, variables, signal) {
|
|
14
|
+
const analysis = analyzeMutation(document, this.manifest);
|
|
15
|
+
let data;
|
|
16
|
+
try {
|
|
17
|
+
data = await this.transport.execute({ query: analysis.query, variables, signal });
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error instanceof GolemPartialCommitError || error instanceof GolemCommitUnknownError) {
|
|
21
|
+
this.invalidateCommitted(analysis);
|
|
22
|
+
}
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
this.invalidateCommitted(analysis);
|
|
26
|
+
return data;
|
|
27
|
+
}
|
|
28
|
+
invalidateCommitted(analysis) {
|
|
29
|
+
const unscoped = analysis.roots.some((root) => root.models.length === 0);
|
|
30
|
+
if (unscoped) {
|
|
31
|
+
void this.client.invalidateQueries({ predicate: (query) => readMeta(query) !== undefined });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const models = analysis.roots.flatMap((root) => root.models);
|
|
35
|
+
if (models.length === 0) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
void this.client.invalidateQueries({
|
|
39
|
+
predicate: (query) => readMeta(query)?.analysis.streams.some((stream) => models.includes(stream.model)) === true,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type DocumentAnalysis } from './analysis.js';
|
|
2
|
+
import type { ClientManifest } from './manifest.js';
|
|
3
|
+
export type Cursor = Readonly<Record<string, unknown>>;
|
|
4
|
+
export type TakeSource = {
|
|
5
|
+
readonly variable: string;
|
|
6
|
+
readonly fallback?: number;
|
|
7
|
+
} | {
|
|
8
|
+
readonly literal: number;
|
|
9
|
+
};
|
|
10
|
+
export interface PageSpec {
|
|
11
|
+
readonly root: string;
|
|
12
|
+
readonly model: string;
|
|
13
|
+
readonly selector: string;
|
|
14
|
+
readonly primaryKey: ReadonlyArray<string>;
|
|
15
|
+
readonly take: TakeSource;
|
|
16
|
+
readonly continuation: string;
|
|
17
|
+
}
|
|
18
|
+
export interface InfiniteQueryPlan {
|
|
19
|
+
readonly spec: PageSpec;
|
|
20
|
+
readonly take: number;
|
|
21
|
+
readonly initialPageParam: Cursor | null;
|
|
22
|
+
getNextPageParam(lastPage: unknown): Cursor | undefined;
|
|
23
|
+
requestFor(pageParam: Cursor | null): {
|
|
24
|
+
readonly query: string;
|
|
25
|
+
readonly variables: Readonly<Record<string, unknown>>;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export declare const CURSOR_VARIABLE = "golemCursor";
|
|
29
|
+
export declare const SKIP_VARIABLE = "golemSkip";
|
|
30
|
+
export declare function pageSpec(analysis: DocumentAnalysis, manifest: ClientManifest): PageSpec;
|
|
31
|
+
export declare function cursorOf(spec: PageSpec, row: Readonly<Record<string, unknown>>): Cursor;
|
|
32
|
+
export declare function golemInfiniteQuery(analysis: DocumentAnalysis, variables: Readonly<Record<string, unknown>>, manifest: ClientManifest): InfiniteQueryPlan;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Kind, OperationTypeNode, print } from 'graphql';
|
|
2
|
+
import { operationOf } from './analysis.js';
|
|
3
|
+
import { GolemConfigurationError, GolemDocumentError, GolemProtocolError } from './errors.js';
|
|
4
|
+
const SKIP_PAST_CURSOR = 1;
|
|
5
|
+
function variableOf(field, name) {
|
|
6
|
+
const value = field.arguments?.find((candidate) => candidate.name.value === name)?.value;
|
|
7
|
+
return value?.kind === Kind.VARIABLE ? value.name.value : undefined;
|
|
8
|
+
}
|
|
9
|
+
function takeOf(field, operation) {
|
|
10
|
+
const value = field.arguments?.find((candidate) => candidate.name.value === 'take')?.value;
|
|
11
|
+
if (value?.kind === Kind.VARIABLE) {
|
|
12
|
+
const name = value.name.value;
|
|
13
|
+
const fallback = operation.variableDefinitions?.find((definition) => definition.variable.name.value === name)?.defaultValue;
|
|
14
|
+
return fallback?.kind === Kind.INT ? { variable: name, fallback: Number(fallback.value) } : { variable: name };
|
|
15
|
+
}
|
|
16
|
+
if (value?.kind === Kind.INT) {
|
|
17
|
+
return { literal: Number(value.value) };
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
export const CURSOR_VARIABLE = 'golemCursor';
|
|
22
|
+
export const SKIP_VARIABLE = 'golemSkip';
|
|
23
|
+
const nameNode = (value) => ({ kind: Kind.NAME, value });
|
|
24
|
+
function continuationOf(document, operation, field, uniqueInput) {
|
|
25
|
+
const taken = operation.variableDefinitions?.find((definition) => definition.variable.name.value === CURSOR_VARIABLE || definition.variable.name.value === SKIP_VARIABLE);
|
|
26
|
+
if (taken !== undefined) {
|
|
27
|
+
throw new GolemDocumentError(`the variable ${taken.variable.name.value} belongs to the client; an infinite query adds it to every page after the first`);
|
|
28
|
+
}
|
|
29
|
+
const paged = {
|
|
30
|
+
...field,
|
|
31
|
+
arguments: [
|
|
32
|
+
...(field.arguments ?? []),
|
|
33
|
+
{ kind: Kind.ARGUMENT, name: nameNode('cursor'), value: { kind: Kind.VARIABLE, name: nameNode(CURSOR_VARIABLE) } },
|
|
34
|
+
{ kind: Kind.ARGUMENT, name: nameNode('skip'), value: { kind: Kind.VARIABLE, name: nameNode(SKIP_VARIABLE) } },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
const widened = {
|
|
38
|
+
...operation,
|
|
39
|
+
variableDefinitions: [
|
|
40
|
+
...(operation.variableDefinitions ?? []),
|
|
41
|
+
{ kind: Kind.VARIABLE_DEFINITION, variable: { kind: Kind.VARIABLE, name: nameNode(CURSOR_VARIABLE) }, type: { kind: Kind.NAMED_TYPE, name: nameNode(uniqueInput) } },
|
|
42
|
+
{ kind: Kind.VARIABLE_DEFINITION, variable: { kind: Kind.VARIABLE, name: nameNode(SKIP_VARIABLE) }, type: { kind: Kind.NAMED_TYPE, name: nameNode('Int') } },
|
|
43
|
+
],
|
|
44
|
+
selectionSet: { ...operation.selectionSet, selections: operation.selectionSet.selections.map((selection) => (selection === field ? paged : selection)) },
|
|
45
|
+
};
|
|
46
|
+
return print({ ...document, definitions: document.definitions.map((definition) => (definition === operation ? widened : definition)) });
|
|
47
|
+
}
|
|
48
|
+
export function pageSpec(analysis, manifest) {
|
|
49
|
+
const operation = operationOf(analysis.document, OperationTypeNode.QUERY);
|
|
50
|
+
const named = (field, name) => field.arguments?.some((candidate) => candidate.name.value === name) === true;
|
|
51
|
+
for (const selection of operation.selectionSet.selections) {
|
|
52
|
+
if (selection.kind === Kind.FIELD && (named(selection, 'cursor') || named(selection, 'skip'))) {
|
|
53
|
+
throw new GolemDocumentError(`${selection.name.value} sets cursor or skip itself; an infinite query builds both from the last row of the previous page`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const roots = operation.selectionSet.selections.filter((selection) => selection.kind === Kind.FIELD && named(selection, 'take'));
|
|
57
|
+
const [field] = roots;
|
|
58
|
+
if (field === undefined || roots.length !== 1) {
|
|
59
|
+
throw new GolemDocumentError(`an infinite query needs exactly one root field with a take argument; found ${roots.length}`);
|
|
60
|
+
}
|
|
61
|
+
const name = field.name.value;
|
|
62
|
+
const root = manifest.roots[name];
|
|
63
|
+
const modelName = root?.models[0];
|
|
64
|
+
const model = modelName === undefined ? undefined : manifest.models[modelName];
|
|
65
|
+
if (root === undefined || root.kind !== 'generated' || root.shape !== 'rows' || modelName === undefined || model === undefined) {
|
|
66
|
+
throw new GolemDocumentError(`${name} cannot be paged: only a generated list root has a cursor`);
|
|
67
|
+
}
|
|
68
|
+
if (model.uniqueInput === null) {
|
|
69
|
+
throw new GolemDocumentError(`${name} cannot be paged: the manifest names no unique input for ${modelName}`);
|
|
70
|
+
}
|
|
71
|
+
const take = takeOf(field, operation);
|
|
72
|
+
if (take === undefined) {
|
|
73
|
+
throw new GolemDocumentError(`${name} must give take a variable or an integer`);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
root: (field.alias ?? field.name).value,
|
|
77
|
+
model: modelName,
|
|
78
|
+
selector: model.cursorSelector,
|
|
79
|
+
primaryKey: model.primaryKey,
|
|
80
|
+
take,
|
|
81
|
+
continuation: continuationOf(analysis.document, operation, field, model.uniqueInput),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export function cursorOf(spec, row) {
|
|
85
|
+
const parts = {};
|
|
86
|
+
for (const field of spec.primaryKey) {
|
|
87
|
+
const value = row[field];
|
|
88
|
+
if (value === undefined || value === null) {
|
|
89
|
+
throw new GolemProtocolError(`a ${spec.model} row on the page lacks ${field}, so no cursor can be built from it`);
|
|
90
|
+
}
|
|
91
|
+
parts[field] = value;
|
|
92
|
+
}
|
|
93
|
+
const [only] = spec.primaryKey;
|
|
94
|
+
return { [spec.selector]: spec.primaryKey.length === 1 && only !== undefined ? parts[only] : parts };
|
|
95
|
+
}
|
|
96
|
+
function pageRows(spec, page) {
|
|
97
|
+
const rows = typeof page === 'object' && page !== null ? page[spec.root] : undefined;
|
|
98
|
+
if (!Array.isArray(rows)) {
|
|
99
|
+
throw new GolemProtocolError(`a page carried no ${spec.root} list`);
|
|
100
|
+
}
|
|
101
|
+
return rows;
|
|
102
|
+
}
|
|
103
|
+
function takeValue(spec, variables) {
|
|
104
|
+
const value = 'literal' in spec.take ? spec.take.literal : (variables[spec.take.variable] ?? spec.take.fallback);
|
|
105
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
|
|
106
|
+
throw new GolemConfigurationError(`an infinite query needs a positive integer take; got ${JSON.stringify(value)}`);
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
export function golemInfiniteQuery(analysis, variables, manifest) {
|
|
111
|
+
const spec = pageSpec(analysis, manifest);
|
|
112
|
+
for (const owned of [CURSOR_VARIABLE, SKIP_VARIABLE]) {
|
|
113
|
+
if (owned in variables) {
|
|
114
|
+
throw new GolemConfigurationError(`the variable ${owned} belongs to the client; an infinite query builds it from the last row of the previous page`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const take = takeValue(spec, variables);
|
|
118
|
+
return {
|
|
119
|
+
spec,
|
|
120
|
+
take,
|
|
121
|
+
initialPageParam: null,
|
|
122
|
+
getNextPageParam(lastPage) {
|
|
123
|
+
const rows = pageRows(spec, lastPage);
|
|
124
|
+
const last = rows.at(-1);
|
|
125
|
+
return rows.length < take || last === undefined ? undefined : cursorOf(spec, last);
|
|
126
|
+
},
|
|
127
|
+
requestFor(pageParam) {
|
|
128
|
+
return pageParam === null
|
|
129
|
+
? { query: analysis.query, variables }
|
|
130
|
+
: { query: spec.continuation, variables: { ...variables, [CURSOR_VARIABLE]: pageParam, [SKIP_VARIABLE]: SKIP_PAST_CURSOR } };
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ClientManifest } from './manifest.js';
|
|
2
|
+
import { type Version } from './merge.js';
|
|
3
|
+
export type RowKey = string;
|
|
4
|
+
export type Occurrences = ReadonlyArray<string>;
|
|
5
|
+
export type EventIdentity = string | number | Readonly<Record<string, unknown>>;
|
|
6
|
+
export interface Eviction {
|
|
7
|
+
readonly data: unknown;
|
|
8
|
+
readonly held: ReadonlySet<RowKey>;
|
|
9
|
+
readonly evicted: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare class Patcher {
|
|
12
|
+
private readonly manifest;
|
|
13
|
+
constructor(manifest: ClientManifest);
|
|
14
|
+
keyOf(typename: string, source: Readonly<Record<string, unknown>>): RowKey | undefined;
|
|
15
|
+
eventKey(model: string, id: EventIdentity, entity: Readonly<Record<string, unknown>> | null): RowKey;
|
|
16
|
+
versions(data: unknown, within?: Occurrences): Map<RowKey, Version>;
|
|
17
|
+
patch(data: unknown, key: RowKey, entity: Readonly<Record<string, unknown>>, within?: Occurrences): unknown;
|
|
18
|
+
remove(data: unknown, keys: ReadonlySet<RowKey>, nullableRoots: ReadonlySet<string>, within?: Occurrences): Eviction;
|
|
19
|
+
private overlay;
|
|
20
|
+
private holdsRows;
|
|
21
|
+
private identify;
|
|
22
|
+
private each;
|
|
23
|
+
private nullableSlot;
|
|
24
|
+
private vacate;
|
|
25
|
+
private rewrite;
|
|
26
|
+
}
|
package/dist/patcher.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { GolemProtocolError } from './errors.js';
|
|
2
|
+
import { parseVersion } from './merge.js';
|
|
3
|
+
const REMOVED = Symbol('removed');
|
|
4
|
+
function isPlainObject(value) {
|
|
5
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
const childPath = (path, name) => (path === '' ? name : `${path}.${name}`);
|
|
8
|
+
export class Patcher {
|
|
9
|
+
manifest;
|
|
10
|
+
constructor(manifest) {
|
|
11
|
+
this.manifest = manifest;
|
|
12
|
+
}
|
|
13
|
+
keyOf(typename, source) {
|
|
14
|
+
const model = this.manifest.models[typename];
|
|
15
|
+
if (model === undefined) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const parts = [];
|
|
19
|
+
for (const field of model.primaryKey) {
|
|
20
|
+
const value = source[field];
|
|
21
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
parts.push(String(value));
|
|
25
|
+
}
|
|
26
|
+
return `${typename}:${parts.join('\u0001')}`;
|
|
27
|
+
}
|
|
28
|
+
eventKey(model, id, entity) {
|
|
29
|
+
const described = this.manifest.models[model];
|
|
30
|
+
if (described === undefined) {
|
|
31
|
+
throw new GolemProtocolError(`an event named the unknown model ${model}`);
|
|
32
|
+
}
|
|
33
|
+
if (entity !== null) {
|
|
34
|
+
const key = this.keyOf(model, entity);
|
|
35
|
+
if (key !== undefined) {
|
|
36
|
+
return key;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const [field] = described.primaryKey;
|
|
40
|
+
const source = typeof id === 'string' || typeof id === 'number' ? (described.primaryKey.length === 1 && field !== undefined ? { [field]: id } : undefined) : id;
|
|
41
|
+
const key = source === undefined ? undefined : this.keyOf(model, source);
|
|
42
|
+
if (key === undefined) {
|
|
43
|
+
throw new GolemProtocolError(`an event for ${model} carries an identity that does not hold ${described.primaryKey.join(', ')}`);
|
|
44
|
+
}
|
|
45
|
+
return key;
|
|
46
|
+
}
|
|
47
|
+
versions(data, within) {
|
|
48
|
+
const found = new Map();
|
|
49
|
+
this.each(data, within, (key, row) => {
|
|
50
|
+
const raw = row._version;
|
|
51
|
+
if (typeof raw !== 'string') {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const version = parseVersion(raw);
|
|
55
|
+
const known = found.get(key);
|
|
56
|
+
if (known === undefined || version > known) {
|
|
57
|
+
found.set(key, version);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return found;
|
|
61
|
+
}
|
|
62
|
+
patch(data, key, entity, within) {
|
|
63
|
+
return this.rewrite(data, within, (candidate, candidateKey) => {
|
|
64
|
+
if (candidateKey !== key) {
|
|
65
|
+
return candidate;
|
|
66
|
+
}
|
|
67
|
+
return this.overlay(candidate, entity);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
remove(data, keys, nullableRoots, within) {
|
|
71
|
+
const slots = { nullableRoots, held: new Set(), evicted: false };
|
|
72
|
+
const next = this.rewrite(data, within, (candidate, candidateKey) => (keys.has(candidateKey) ? REMOVED : candidate), slots);
|
|
73
|
+
return { data: next, held: slots.held, evicted: slots.evicted };
|
|
74
|
+
}
|
|
75
|
+
overlay(row, source) {
|
|
76
|
+
const typename = row.__typename;
|
|
77
|
+
const relations = typeof typename === 'string' ? this.manifest.models[typename]?.relations ?? {} : {};
|
|
78
|
+
const computed = typeof typename === 'string' ? this.manifest.models[typename]?.computed ?? {} : {};
|
|
79
|
+
const complete = Object.keys(row).every((name) => name in source || name === '__typename' || name in relations || (computed[name]?.length ?? 0) > 0 || this.holdsRows(row[name]));
|
|
80
|
+
let next;
|
|
81
|
+
for (const [name, value] of Object.entries(source)) {
|
|
82
|
+
if (name === '_version' && !complete) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (name === '__typename' || !(name in row) || name in relations || row[name] === value) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (this.holdsRows(value) || this.holdsRows(row[name])) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
next ??= { ...row };
|
|
92
|
+
next[name] = value;
|
|
93
|
+
}
|
|
94
|
+
return next ?? row;
|
|
95
|
+
}
|
|
96
|
+
holdsRows(value) {
|
|
97
|
+
if (Array.isArray(value)) {
|
|
98
|
+
return value.some((item) => this.identify(item) !== undefined);
|
|
99
|
+
}
|
|
100
|
+
return this.identify(value) !== undefined;
|
|
101
|
+
}
|
|
102
|
+
identify(value) {
|
|
103
|
+
if (!isPlainObject(value) || typeof value.__typename !== 'string') {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
return this.keyOf(value.__typename, value);
|
|
107
|
+
}
|
|
108
|
+
each(value, within, visit, path = '') {
|
|
109
|
+
if (Array.isArray(value)) {
|
|
110
|
+
for (const item of value) {
|
|
111
|
+
this.each(item, within, visit, path);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!isPlainObject(value)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const key = this.identify(value);
|
|
119
|
+
if (key !== undefined && (within === undefined || within.includes(path))) {
|
|
120
|
+
visit(key, value);
|
|
121
|
+
}
|
|
122
|
+
for (const [name, child] of Object.entries(value)) {
|
|
123
|
+
this.each(child, within, visit, childPath(path, name));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
nullableSlot(parent, name, depth, slots) {
|
|
127
|
+
if (depth === 0) {
|
|
128
|
+
return slots.nullableRoots.has(name);
|
|
129
|
+
}
|
|
130
|
+
const typename = parent.__typename;
|
|
131
|
+
const relation = typeof typename === 'string' ? this.manifest.models[typename]?.relations[name] : undefined;
|
|
132
|
+
return relation !== undefined && !relation.list && relation.nullable;
|
|
133
|
+
}
|
|
134
|
+
vacate(child, name, depth, slots, parent) {
|
|
135
|
+
if (slots !== undefined) {
|
|
136
|
+
slots.evicted = true;
|
|
137
|
+
}
|
|
138
|
+
if (slots !== undefined && this.nullableSlot(parent, name, depth, slots)) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const key = this.identify(child);
|
|
142
|
+
if (slots !== undefined && key !== undefined) {
|
|
143
|
+
slots.held.add(key);
|
|
144
|
+
}
|
|
145
|
+
return child;
|
|
146
|
+
}
|
|
147
|
+
rewrite(value, within, transform, slots, depth = 0, path = '') {
|
|
148
|
+
if (Array.isArray(value)) {
|
|
149
|
+
let changed = false;
|
|
150
|
+
const next = [];
|
|
151
|
+
for (const item of value) {
|
|
152
|
+
const rewritten = this.rewrite(item, within, transform, slots, depth + 1, path);
|
|
153
|
+
if (rewritten === REMOVED) {
|
|
154
|
+
changed = true;
|
|
155
|
+
if (slots !== undefined) {
|
|
156
|
+
slots.evicted = true;
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (rewritten !== item) {
|
|
161
|
+
changed = true;
|
|
162
|
+
}
|
|
163
|
+
next.push(rewritten);
|
|
164
|
+
}
|
|
165
|
+
return changed ? next : value;
|
|
166
|
+
}
|
|
167
|
+
if (!isPlainObject(value)) {
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
let copy;
|
|
171
|
+
for (const [name, child] of Object.entries(value)) {
|
|
172
|
+
const rewritten = this.rewrite(child, within, transform, slots, depth + 1, childPath(path, name));
|
|
173
|
+
const replacement = rewritten === REMOVED ? this.vacate(child, name, depth, slots, value) : rewritten;
|
|
174
|
+
if (replacement !== child) {
|
|
175
|
+
copy ??= { ...value };
|
|
176
|
+
copy[name] = replacement;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const current = copy ?? value;
|
|
180
|
+
const key = this.identify(current);
|
|
181
|
+
if (key === undefined || (within !== undefined && !within.includes(path))) {
|
|
182
|
+
return current;
|
|
183
|
+
}
|
|
184
|
+
return transform(current, key);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type GolemError, GolemProtocolError, type GraphQLResponseBody } from './errors.js';
|
|
2
|
+
export declare const PROTOCOL_VERSION = 1;
|
|
3
|
+
export interface GolemBlock {
|
|
4
|
+
readonly protocol: number;
|
|
5
|
+
readonly contract: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ContractManifest {
|
|
8
|
+
readonly contract: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function golemBlock(manifest: ContractManifest): GolemBlock;
|
|
11
|
+
export declare const CloseCode: {
|
|
12
|
+
readonly BadRequest: 4400;
|
|
13
|
+
readonly Unauthorized: 4401;
|
|
14
|
+
readonly Forbidden: 4403;
|
|
15
|
+
readonly InitTimeout: 4408;
|
|
16
|
+
readonly SubscriberExists: 4409;
|
|
17
|
+
readonly HandshakeRefused: 4426;
|
|
18
|
+
readonly TooManyInits: 4429;
|
|
19
|
+
};
|
|
20
|
+
export type SessionStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'unauthenticated' | 'forbidden' | 'invalid' | 'protocol-mismatch' | 'contract-mismatch' | 'deadline-required';
|
|
21
|
+
export type CloseDisposition = {
|
|
22
|
+
readonly action: 'retry';
|
|
23
|
+
} | {
|
|
24
|
+
readonly action: 'reauthenticate';
|
|
25
|
+
} | {
|
|
26
|
+
readonly action: 'fatal';
|
|
27
|
+
readonly status: SessionStatus;
|
|
28
|
+
readonly error: GolemError | GolemProtocolError;
|
|
29
|
+
};
|
|
30
|
+
export declare function handshakeRefusal(reason: string): CloseDisposition & {
|
|
31
|
+
readonly action: 'fatal';
|
|
32
|
+
};
|
|
33
|
+
export declare function classifyClose(code: number, reason: string, acknowledged: boolean): CloseDisposition;
|
|
34
|
+
export declare const BACKOFF_BASE_MS = 250;
|
|
35
|
+
export declare const BACKOFF_CAP_MS = 30000;
|
|
36
|
+
export declare function backoffCeiling(attempt: number): number;
|
|
37
|
+
export declare function backoffDelay(attempt: number, random?: () => number): number;
|
|
38
|
+
export declare function isFatalHandshakeStatus(status: SessionStatus): boolean;
|
|
39
|
+
export type AckOutcome = {
|
|
40
|
+
readonly kind: 'ack';
|
|
41
|
+
} | {
|
|
42
|
+
readonly kind: 'event';
|
|
43
|
+
readonly body: GraphQLResponseBody;
|
|
44
|
+
} | {
|
|
45
|
+
readonly kind: 'error';
|
|
46
|
+
readonly errors: GolemError[];
|
|
47
|
+
} | {
|
|
48
|
+
readonly kind: 'violation';
|
|
49
|
+
readonly error: GolemProtocolError;
|
|
50
|
+
};
|
|
51
|
+
export declare class AckTracker {
|
|
52
|
+
private acknowledged;
|
|
53
|
+
get live(): boolean;
|
|
54
|
+
reset(): void;
|
|
55
|
+
accept(body: GraphQLResponseBody): AckOutcome;
|
|
56
|
+
}
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { GolemContractMismatchError, GolemDeadlineRequiredError, GolemProtocolError, GolemProtocolMismatchError, GolemUnauthenticatedError, GolemError as GolemErrorClass, CODE_CONTRACT_MISMATCH, CODE_DEADLINE_REQUIRED, CODE_PROTOCOL_MISMATCH, errorsOf } from './errors.js';
|
|
2
|
+
export const PROTOCOL_VERSION = 1;
|
|
3
|
+
export function golemBlock(manifest) {
|
|
4
|
+
return { protocol: PROTOCOL_VERSION, contract: manifest.contract };
|
|
5
|
+
}
|
|
6
|
+
export const CloseCode = {
|
|
7
|
+
BadRequest: 4400,
|
|
8
|
+
Unauthorized: 4401,
|
|
9
|
+
Forbidden: 4403,
|
|
10
|
+
InitTimeout: 4408,
|
|
11
|
+
SubscriberExists: 4409,
|
|
12
|
+
HandshakeRefused: 4426,
|
|
13
|
+
TooManyInits: 4429,
|
|
14
|
+
};
|
|
15
|
+
const REFUSAL_PREFIX = 'golem:';
|
|
16
|
+
function refusalShape(message, code) {
|
|
17
|
+
return { message, extensions: { code } };
|
|
18
|
+
}
|
|
19
|
+
export function handshakeRefusal(reason) {
|
|
20
|
+
const detail = reason.startsWith(REFUSAL_PREFIX) ? reason.slice(REFUSAL_PREFIX.length) : reason;
|
|
21
|
+
switch (detail) {
|
|
22
|
+
case 'protocol':
|
|
23
|
+
return {
|
|
24
|
+
action: 'fatal',
|
|
25
|
+
status: 'protocol-mismatch',
|
|
26
|
+
error: new GolemProtocolMismatchError(refusalShape('the server refused the live protocol version this client speaks', CODE_PROTOCOL_MISMATCH)),
|
|
27
|
+
};
|
|
28
|
+
case 'contract':
|
|
29
|
+
return {
|
|
30
|
+
action: 'fatal',
|
|
31
|
+
status: 'contract-mismatch',
|
|
32
|
+
error: new GolemContractMismatchError(refusalShape('the client was generated from a different contract than this server; regenerate the client', CODE_CONTRACT_MISMATCH)),
|
|
33
|
+
};
|
|
34
|
+
case CODE_DEADLINE_REQUIRED:
|
|
35
|
+
return {
|
|
36
|
+
action: 'fatal',
|
|
37
|
+
status: 'deadline-required',
|
|
38
|
+
error: new GolemDeadlineRequiredError(refusalShape('the server refused the socket because the principal has no credential expiry', CODE_DEADLINE_REQUIRED)),
|
|
39
|
+
};
|
|
40
|
+
default:
|
|
41
|
+
return {
|
|
42
|
+
action: 'fatal',
|
|
43
|
+
status: 'protocol-mismatch',
|
|
44
|
+
error: new GolemProtocolError(`the server refused the handshake with an unrecognised reason ${JSON.stringify(reason)}`),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function classifyClose(code, reason, acknowledged) {
|
|
49
|
+
switch (code) {
|
|
50
|
+
case CloseCode.HandshakeRefused:
|
|
51
|
+
return handshakeRefusal(reason);
|
|
52
|
+
case CloseCode.Unauthorized:
|
|
53
|
+
if (acknowledged) {
|
|
54
|
+
return { action: 'reauthenticate' };
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
action: 'fatal',
|
|
58
|
+
status: 'unauthenticated',
|
|
59
|
+
error: new GolemUnauthenticatedError('the server closed the socket with 4401 before the connection was acknowledged: the credentials were rejected'),
|
|
60
|
+
};
|
|
61
|
+
case CloseCode.Forbidden:
|
|
62
|
+
return { action: 'fatal', status: 'forbidden', error: new GolemErrorClass({ message: 'the server refused connection initialisation (4403)', extensions: { code: 'GOLEM_FORBIDDEN' } }, false) };
|
|
63
|
+
case CloseCode.BadRequest:
|
|
64
|
+
case CloseCode.SubscriberExists:
|
|
65
|
+
case CloseCode.TooManyInits:
|
|
66
|
+
return { action: 'fatal', status: 'invalid', error: new GolemProtocolError(`the server closed the socket with ${code}: the client broke the protocol`) };
|
|
67
|
+
default:
|
|
68
|
+
return { action: 'retry' };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export const BACKOFF_BASE_MS = 250;
|
|
72
|
+
export const BACKOFF_CAP_MS = 30_000;
|
|
73
|
+
export function backoffCeiling(attempt) {
|
|
74
|
+
return Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, attempt));
|
|
75
|
+
}
|
|
76
|
+
export function backoffDelay(attempt, random = Math.random) {
|
|
77
|
+
return Math.floor(random() * backoffCeiling(attempt));
|
|
78
|
+
}
|
|
79
|
+
export function isFatalHandshakeStatus(status) {
|
|
80
|
+
return status === 'protocol-mismatch' || status === 'contract-mismatch';
|
|
81
|
+
}
|
|
82
|
+
export class AckTracker {
|
|
83
|
+
acknowledged = false;
|
|
84
|
+
get live() {
|
|
85
|
+
return this.acknowledged;
|
|
86
|
+
}
|
|
87
|
+
reset() {
|
|
88
|
+
this.acknowledged = false;
|
|
89
|
+
}
|
|
90
|
+
accept(body) {
|
|
91
|
+
if (body.extensions?.golem?.live === true) {
|
|
92
|
+
if (this.acknowledged) {
|
|
93
|
+
return { kind: 'violation', error: new GolemProtocolError('a second live acknowledgement arrived for one operation') };
|
|
94
|
+
}
|
|
95
|
+
if (body.data !== null || (body.errors?.length ?? 0) !== 0) {
|
|
96
|
+
return { kind: 'violation', error: new GolemProtocolError('the live acknowledgement carried data or errors') };
|
|
97
|
+
}
|
|
98
|
+
this.acknowledged = true;
|
|
99
|
+
return { kind: 'ack' };
|
|
100
|
+
}
|
|
101
|
+
if ((body.errors?.length ?? 0) !== 0) {
|
|
102
|
+
return { kind: 'error', errors: errorsOf(body) };
|
|
103
|
+
}
|
|
104
|
+
if (!this.acknowledged) {
|
|
105
|
+
return { kind: 'violation', error: new GolemProtocolError('an event arrived before the live acknowledgement') };
|
|
106
|
+
}
|
|
107
|
+
return { kind: 'event', body };
|
|
108
|
+
}
|
|
109
|
+
}
|