@syncular/server 0.15.45 → 0.15.47
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/README.md +144 -7
- package/dist/admin.d.ts +10 -4
- package/dist/admin.js +10 -0
- package/dist/authoritative-query.d.ts +20 -0
- package/dist/authoritative-query.js +184 -0
- package/dist/context.d.ts +11 -1
- package/dist/context.js +2 -0
- package/dist/d1-storage.d.ts +10 -1
- package/dist/d1-storage.js +216 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +43 -1
- package/dist/events.d.ts +52 -3
- package/dist/handler.js +4 -1
- package/dist/index-bun.d.ts +2 -0
- package/dist/index-bun.js +2 -0
- package/dist/index-node.d.ts +2 -0
- package/dist/index-node.js +2 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +7 -6
- package/dist/operations-realtime.d.ts +16 -0
- package/dist/operations-realtime.js +196 -0
- package/dist/operations.d.ts +97 -0
- package/dist/operations.js +392 -0
- package/dist/postgres-storage.d.ts +11 -2
- package/dist/postgres-storage.js +220 -0
- package/dist/pull.js +1 -1
- package/dist/push.d.ts +8 -2
- package/dist/push.js +75 -21
- package/dist/reactions.d.ts +167 -0
- package/dist/reactions.js +442 -0
- package/dist/realtime.js +4 -1
- package/dist/sqlite-blob-store.d.ts +4 -9
- package/dist/sqlite-blob-store.js +5 -10
- package/dist/sqlite-bun-driver.d.ts +11 -0
- package/dist/sqlite-bun-driver.js +27 -0
- package/dist/sqlite-bun.d.ts +24 -0
- package/dist/sqlite-bun.js +40 -0
- package/dist/sqlite-dialect.d.ts +8 -8
- package/dist/sqlite-dialect.js +22 -2
- package/dist/sqlite-driver.d.ts +26 -0
- package/dist/sqlite-driver.js +8 -0
- package/dist/sqlite-image.d.ts +7 -9
- package/dist/sqlite-image.js +26 -28
- package/dist/sqlite-lease-store.d.ts +4 -9
- package/dist/sqlite-lease-store.js +5 -10
- package/dist/sqlite-node-driver.d.ts +10 -0
- package/dist/sqlite-node-driver.js +30 -0
- package/dist/sqlite-node.d.ts +24 -0
- package/dist/sqlite-node.js +50 -0
- package/dist/sqlite-segment-store.d.ts +4 -10
- package/dist/sqlite-segment-store.js +6 -9
- package/dist/sqlite-storage.d.ts +13 -12
- package/dist/sqlite-storage.js +223 -5
- package/dist/storage-errors.js +4 -1
- package/dist/storage.d.ts +109 -0
- package/dist/validate.js +1 -0
- package/package.json +18 -3
- package/src/admin.ts +27 -3
- package/src/authoritative-query.ts +218 -0
- package/src/context.ts +12 -1
- package/src/d1-storage.ts +352 -0
- package/src/errors.ts +43 -1
- package/src/events.ts +64 -2
- package/src/handler.ts +13 -1
- package/src/index-bun.ts +9 -0
- package/src/index-node.ts +9 -0
- package/src/index.ts +40 -6
- package/src/operations-realtime.ts +272 -0
- package/src/operations.ts +720 -0
- package/src/postgres-storage.ts +351 -0
- package/src/pull.ts +1 -1
- package/src/push.ts +97 -29
- package/src/reactions.ts +741 -0
- package/src/realtime.ts +7 -1
- package/src/sqlite-blob-store.ts +11 -10
- package/src/sqlite-bun-driver.ts +42 -0
- package/src/sqlite-bun.ts +53 -0
- package/src/sqlite-dialect.ts +27 -7
- package/src/sqlite-driver.ts +44 -0
- package/src/sqlite-image.ts +44 -49
- package/src/sqlite-lease-store.ts +11 -10
- package/src/sqlite-node-driver.ts +46 -0
- package/src/sqlite-node.ts +62 -0
- package/src/sqlite-segment-store.ts +11 -11
- package/src/sqlite-storage.ts +378 -7
- package/src/storage-errors.ts +4 -1
- package/src/storage.ts +165 -0
- package/src/validate.ts +1 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable post-commit reactions. Planning runs inside the authoritative push
|
|
3
|
+
* transaction and may only produce bounded data. Delivery runs later under a
|
|
4
|
+
* lease and is at-least-once, so handlers receive the stable idempotency key.
|
|
5
|
+
*/
|
|
6
|
+
import type { SyncularServerEvents } from './events.js';
|
|
7
|
+
import type { DurableJsonValue, NewReaction, ServerStorage } from './storage.js';
|
|
8
|
+
import type { CommitValidationReader, ValidateCommitOperation } from './validate.js';
|
|
9
|
+
export declare const MAX_REACTIONS_PER_COMMIT = 100;
|
|
10
|
+
export declare const MAX_REACTION_PAYLOAD_BYTES: number;
|
|
11
|
+
export declare const MAX_REACTION_FAILURE_DETAILS_BYTES: number;
|
|
12
|
+
export declare const DEFAULT_REACTION_MAX_ATTEMPTS = 10;
|
|
13
|
+
export declare const DEFAULT_REACTION_LEASE_MS = 30000;
|
|
14
|
+
export declare const DEFAULT_REACTION_INITIAL_BACKOFF_MS = 1000;
|
|
15
|
+
export declare const DEFAULT_REACTION_MAX_BACKOFF_MS: number;
|
|
16
|
+
export interface ReactionRetentionPolicy {
|
|
17
|
+
/** Keep completed rows for at least this duration (default 30 days). */
|
|
18
|
+
readonly completedRetentionMs: number;
|
|
19
|
+
/** Keep dead letters for at least this duration (default 90 days). */
|
|
20
|
+
readonly deadLetterRetentionMs: number;
|
|
21
|
+
/** Maximum terminal rows removed by one pass (default 1000). */
|
|
22
|
+
readonly batchSize: number;
|
|
23
|
+
}
|
|
24
|
+
export declare const DEFAULT_REACTION_RETENTION: ReactionRetentionPolicy;
|
|
25
|
+
export type ReactionTypeMap = Readonly<Record<string, DurableJsonValue>>;
|
|
26
|
+
export interface ReactionPlan {
|
|
27
|
+
/** Unique within the source client commit. */
|
|
28
|
+
readonly key: string;
|
|
29
|
+
readonly type: string;
|
|
30
|
+
readonly version: number;
|
|
31
|
+
readonly payload: DurableJsonValue;
|
|
32
|
+
readonly maxAttempts?: number;
|
|
33
|
+
}
|
|
34
|
+
export type PlannedReaction<Reactions extends ReactionTypeMap = ReactionTypeMap> = {
|
|
35
|
+
[Type in keyof Reactions & string]: {
|
|
36
|
+
/** Unique within the source client commit. */
|
|
37
|
+
readonly key: string;
|
|
38
|
+
readonly type: Type;
|
|
39
|
+
readonly version: number;
|
|
40
|
+
readonly payload: Reactions[Type];
|
|
41
|
+
readonly maxAttempts?: number;
|
|
42
|
+
};
|
|
43
|
+
}[keyof Reactions & string];
|
|
44
|
+
export interface ReactionPlannerInput {
|
|
45
|
+
readonly clientId: string;
|
|
46
|
+
readonly clientCommitId: string;
|
|
47
|
+
readonly actorId: string;
|
|
48
|
+
readonly partition: string;
|
|
49
|
+
readonly operations: readonly ValidateCommitOperation[];
|
|
50
|
+
/** Candidate-state reads from the still-open authoritative transaction. */
|
|
51
|
+
readonly read: CommitValidationReader;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A pure planner over an accepted candidate commit. It may read candidate
|
|
55
|
+
* state and return durable data. It must not execute user-visible side effects.
|
|
56
|
+
*/
|
|
57
|
+
export type ReactionPlanner<Reactions extends ReactionTypeMap = ReactionTypeMap> = (input: ReactionPlannerInput) => readonly PlannedReaction<Reactions>[] | Promise<readonly PlannedReaction<Reactions>[]>;
|
|
58
|
+
/** Erased planner shape stored on non-generic server configuration. */
|
|
59
|
+
export type AnyReactionPlanner = (input: ReactionPlannerInput) => readonly ReactionPlan[] | Promise<readonly ReactionPlan[]>;
|
|
60
|
+
export interface ReactionHandlerInput<Payload extends DurableJsonValue> {
|
|
61
|
+
readonly partition: string;
|
|
62
|
+
readonly idempotencyKey: string;
|
|
63
|
+
readonly type: string;
|
|
64
|
+
readonly version: number;
|
|
65
|
+
readonly payload: Payload;
|
|
66
|
+
readonly attempt: number;
|
|
67
|
+
readonly maxAttempts: number;
|
|
68
|
+
readonly sourceClientId: string;
|
|
69
|
+
readonly sourceClientCommitId: string;
|
|
70
|
+
readonly sourceCommitSeq: number;
|
|
71
|
+
/** Extend a long-running handler's lease; throws after ownership is lost. */
|
|
72
|
+
readonly extendLease: () => Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
export type ReactionHandler<Payload extends DurableJsonValue> = (input: ReactionHandlerInput<Payload>) => void | Promise<void>;
|
|
75
|
+
export type ReactionHandlers<Reactions extends ReactionTypeMap = ReactionTypeMap> = {
|
|
76
|
+
readonly [Type in keyof Reactions & string]: ReactionHandler<Reactions[Type]>;
|
|
77
|
+
};
|
|
78
|
+
/** Stable handler idempotency key for one planned item in a client commit. */
|
|
79
|
+
export declare function reactionIdempotencyKey(partition: string, clientId: string, clientCommitId: string, plannerKey: string): string;
|
|
80
|
+
export interface PreparedReaction {
|
|
81
|
+
readonly idempotencyKey: string;
|
|
82
|
+
readonly type: string;
|
|
83
|
+
readonly version: number;
|
|
84
|
+
readonly payload: DurableJsonValue;
|
|
85
|
+
readonly maxAttempts: number;
|
|
86
|
+
}
|
|
87
|
+
/** Internal push seam, exported for focused planner tests and custom hosts. */
|
|
88
|
+
export declare function prepareReactions(planner: AnyReactionPlanner, input: ReactionPlannerInput): Promise<PreparedReaction[]>;
|
|
89
|
+
declare class ReactionDeliveryError extends Error {
|
|
90
|
+
readonly code: string;
|
|
91
|
+
readonly details?: {
|
|
92
|
+
readonly [key: string]: DurableJsonValue;
|
|
93
|
+
};
|
|
94
|
+
constructor(name: string, code: string, details?: {
|
|
95
|
+
readonly [key: string]: DurableJsonValue;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** A handler failure that should be retried until its attempt limit. */
|
|
99
|
+
export declare class RetryableReactionError extends ReactionDeliveryError {
|
|
100
|
+
constructor(code: string, details?: {
|
|
101
|
+
readonly [key: string]: DurableJsonValue;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/** A handler failure that should be dead-lettered immediately. */
|
|
105
|
+
export declare class PermanentReactionError extends ReactionDeliveryError {
|
|
106
|
+
constructor(code: string, details?: {
|
|
107
|
+
readonly [key: string]: DurableJsonValue;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
export interface ReactionRunnerOptions<Reactions extends ReactionTypeMap = ReactionTypeMap> {
|
|
111
|
+
readonly storage: ServerStorage;
|
|
112
|
+
readonly partition: string;
|
|
113
|
+
readonly workerId: string;
|
|
114
|
+
readonly handlers: ReactionHandlers<Reactions>;
|
|
115
|
+
readonly events?: SyncularServerEvents;
|
|
116
|
+
readonly clock?: () => number;
|
|
117
|
+
readonly leaseDurationMs?: number;
|
|
118
|
+
readonly batchSize?: number;
|
|
119
|
+
readonly initialBackoffMs?: number;
|
|
120
|
+
readonly maxBackoffMs?: number;
|
|
121
|
+
}
|
|
122
|
+
export interface ReactionRunResult {
|
|
123
|
+
readonly claimed: number;
|
|
124
|
+
readonly completed: number;
|
|
125
|
+
readonly retried: number;
|
|
126
|
+
readonly deadLettered: number;
|
|
127
|
+
/** Lease ownership changed before this worker could persist its outcome. */
|
|
128
|
+
readonly lostLeases: number;
|
|
129
|
+
}
|
|
130
|
+
export interface PruneReactionsOptions {
|
|
131
|
+
readonly storage: ServerStorage;
|
|
132
|
+
readonly partition: string;
|
|
133
|
+
readonly nowMs: number;
|
|
134
|
+
readonly retention?: Partial<ReactionRetentionPolicy>;
|
|
135
|
+
readonly events?: SyncularServerEvents;
|
|
136
|
+
}
|
|
137
|
+
export interface ReactionPruneResult {
|
|
138
|
+
readonly completedBeforeMs: number;
|
|
139
|
+
readonly deadLetterBeforeMs: number;
|
|
140
|
+
readonly removedCompleted: number;
|
|
141
|
+
readonly removedDeadLetter: number;
|
|
142
|
+
/** True when the bounded pass filled its batch and another pass may help. */
|
|
143
|
+
readonly mayHaveMore: boolean;
|
|
144
|
+
}
|
|
145
|
+
/** Host-driven worker. Call `runOnce` from the host scheduler or queue wake. */
|
|
146
|
+
export declare class ReactionRunner<Reactions extends ReactionTypeMap = ReactionTypeMap> {
|
|
147
|
+
#private;
|
|
148
|
+
constructor(options: ReactionRunnerOptions<Reactions>);
|
|
149
|
+
runOnce(): Promise<ReactionRunResult>;
|
|
150
|
+
}
|
|
151
|
+
/** Explicit operator action for a dead-lettered reaction. */
|
|
152
|
+
export declare function retryDeadLetterReaction(options: {
|
|
153
|
+
readonly storage: ServerStorage;
|
|
154
|
+
readonly partition: string;
|
|
155
|
+
readonly idempotencyKey: string;
|
|
156
|
+
readonly nowMs?: number;
|
|
157
|
+
}): Promise<boolean>;
|
|
158
|
+
/** Delete one bounded batch of aged completed and dead-lettered rows. */
|
|
159
|
+
export declare function pruneReactions(options: PruneReactionsOptions): Promise<ReactionPruneResult>;
|
|
160
|
+
/** Helper used by the push path after commit sequence allocation. */
|
|
161
|
+
export declare function toNewReactions(prepared: readonly PreparedReaction[], source: {
|
|
162
|
+
readonly clientId: string;
|
|
163
|
+
readonly clientCommitId: string;
|
|
164
|
+
readonly commitSeq: number;
|
|
165
|
+
readonly createdAtMs: number;
|
|
166
|
+
}): NewReaction[];
|
|
167
|
+
export {};
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { emitEvent } from './events.js';
|
|
2
|
+
export const MAX_REACTIONS_PER_COMMIT = 100;
|
|
3
|
+
export const MAX_REACTION_PAYLOAD_BYTES = 64 * 1024;
|
|
4
|
+
export const MAX_REACTION_FAILURE_DETAILS_BYTES = 8 * 1024;
|
|
5
|
+
export const DEFAULT_REACTION_MAX_ATTEMPTS = 10;
|
|
6
|
+
export const DEFAULT_REACTION_LEASE_MS = 30_000;
|
|
7
|
+
export const DEFAULT_REACTION_INITIAL_BACKOFF_MS = 1_000;
|
|
8
|
+
export const DEFAULT_REACTION_MAX_BACKOFF_MS = 5 * 60_000;
|
|
9
|
+
export const DEFAULT_REACTION_RETENTION = {
|
|
10
|
+
completedRetentionMs: 30 * 24 * 60 * 60 * 1000,
|
|
11
|
+
deadLetterRetentionMs: 90 * 24 * 60 * 60 * 1000,
|
|
12
|
+
batchSize: 1_000,
|
|
13
|
+
};
|
|
14
|
+
function normalizedJson(value, path, depth = 0) {
|
|
15
|
+
if (depth > 16)
|
|
16
|
+
throw new Error(`${path} exceeds the maximum JSON depth`);
|
|
17
|
+
if (value === null ||
|
|
18
|
+
typeof value === 'boolean' ||
|
|
19
|
+
typeof value === 'string') {
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
if (typeof value === 'number') {
|
|
23
|
+
if (!Number.isFinite(value))
|
|
24
|
+
throw new Error(`${path} must be finite`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
29
|
+
if (key === 'length')
|
|
30
|
+
continue;
|
|
31
|
+
if (typeof key !== 'string' ||
|
|
32
|
+
!/^(?:0|[1-9][0-9]*)$/.test(key) ||
|
|
33
|
+
Number(key) >= value.length) {
|
|
34
|
+
throw new Error(`${path} arrays cannot carry extra properties`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const output = [];
|
|
38
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
39
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
40
|
+
if (descriptor === undefined || !('value' in descriptor)) {
|
|
41
|
+
throw new Error(`${path}[${index}] must be a plain JSON value`);
|
|
42
|
+
}
|
|
43
|
+
output.push(normalizedJson(descriptor.value, `${path}[${index}]`, depth + 1));
|
|
44
|
+
}
|
|
45
|
+
return output;
|
|
46
|
+
}
|
|
47
|
+
if (typeof value !== 'object') {
|
|
48
|
+
throw new Error(`${path} must contain only JSON values`);
|
|
49
|
+
}
|
|
50
|
+
const prototype = Object.getPrototypeOf(value);
|
|
51
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
52
|
+
throw new Error(`${path} must contain only plain JSON objects`);
|
|
53
|
+
}
|
|
54
|
+
const output = {};
|
|
55
|
+
const entries = [];
|
|
56
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
57
|
+
if (typeof key !== 'string') {
|
|
58
|
+
throw new Error(`${path} cannot contain symbol keys`);
|
|
59
|
+
}
|
|
60
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
61
|
+
if (descriptor === undefined ||
|
|
62
|
+
!descriptor.enumerable ||
|
|
63
|
+
!('value' in descriptor)) {
|
|
64
|
+
throw new Error(`${path}.${key} must be an enumerable data property`);
|
|
65
|
+
}
|
|
66
|
+
entries.push([key, descriptor.value]);
|
|
67
|
+
}
|
|
68
|
+
for (const [key, entry] of entries.sort(([left], [right]) => left.localeCompare(right))) {
|
|
69
|
+
Object.defineProperty(output, key, {
|
|
70
|
+
value: normalizedJson(entry, `${path}.${key}`, depth + 1),
|
|
71
|
+
enumerable: true,
|
|
72
|
+
configurable: true,
|
|
73
|
+
writable: true,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return output;
|
|
77
|
+
}
|
|
78
|
+
function boundedJson(value, path, maxBytes) {
|
|
79
|
+
const normalized = normalizedJson(value, path);
|
|
80
|
+
if (new TextEncoder().encode(JSON.stringify(normalized)).byteLength > maxBytes) {
|
|
81
|
+
throw new Error(`${path} exceeds ${maxBytes} persisted bytes`);
|
|
82
|
+
}
|
|
83
|
+
return normalized;
|
|
84
|
+
}
|
|
85
|
+
function assertName(value, field, maxBytes) {
|
|
86
|
+
if (!/^[A-Za-z][A-Za-z0-9._:-]*$/.test(value) ||
|
|
87
|
+
new TextEncoder().encode(value).byteLength > maxBytes) {
|
|
88
|
+
throw new Error(`${field} must be a code-like string no longer than ${maxBytes} bytes`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Stable handler idempotency key for one planned item in a client commit. */
|
|
92
|
+
export function reactionIdempotencyKey(partition, clientId, clientCommitId, plannerKey) {
|
|
93
|
+
return JSON.stringify([partition, clientId, clientCommitId, plannerKey]);
|
|
94
|
+
}
|
|
95
|
+
/** Internal push seam, exported for focused planner tests and custom hosts. */
|
|
96
|
+
export async function prepareReactions(planner, input) {
|
|
97
|
+
const planned = await planner(input);
|
|
98
|
+
if (!Array.isArray(planned)) {
|
|
99
|
+
throw new Error('reaction planner must return an array');
|
|
100
|
+
}
|
|
101
|
+
if (planned.length > MAX_REACTIONS_PER_COMMIT) {
|
|
102
|
+
throw new Error(`reaction planner returned more than ${MAX_REACTIONS_PER_COMMIT} records`);
|
|
103
|
+
}
|
|
104
|
+
const keys = new Set();
|
|
105
|
+
return planned.map((reaction, index) => {
|
|
106
|
+
assertName(reaction.type, `reaction[${index}].type`, 128);
|
|
107
|
+
if (reaction.key.length === 0 ||
|
|
108
|
+
new TextEncoder().encode(reaction.key).byteLength > 256) {
|
|
109
|
+
throw new Error(`reaction[${index}].key must be non-empty and no longer than 256 bytes`);
|
|
110
|
+
}
|
|
111
|
+
if (keys.has(reaction.key)) {
|
|
112
|
+
throw new Error(`reaction planner returned duplicate key at index ${index}`);
|
|
113
|
+
}
|
|
114
|
+
keys.add(reaction.key);
|
|
115
|
+
if (!Number.isSafeInteger(reaction.version) ||
|
|
116
|
+
reaction.version < 1 ||
|
|
117
|
+
reaction.version > 2_147_483_647) {
|
|
118
|
+
throw new Error(`reaction[${index}].version must be a positive int32`);
|
|
119
|
+
}
|
|
120
|
+
const maxAttempts = reaction.maxAttempts ?? DEFAULT_REACTION_MAX_ATTEMPTS;
|
|
121
|
+
if (!Number.isSafeInteger(maxAttempts) ||
|
|
122
|
+
maxAttempts < 1 ||
|
|
123
|
+
maxAttempts > 100) {
|
|
124
|
+
throw new Error(`reaction[${index}].maxAttempts must be from 1 through 100`);
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
idempotencyKey: reactionIdempotencyKey(input.partition, input.clientId, input.clientCommitId, reaction.key),
|
|
128
|
+
type: reaction.type,
|
|
129
|
+
version: reaction.version,
|
|
130
|
+
payload: boundedJson(reaction.payload, `reaction[${index}].payload`, MAX_REACTION_PAYLOAD_BYTES),
|
|
131
|
+
maxAttempts,
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
class ReactionDeliveryError extends Error {
|
|
136
|
+
code;
|
|
137
|
+
details;
|
|
138
|
+
constructor(name, code, details) {
|
|
139
|
+
super(code);
|
|
140
|
+
this.name = name;
|
|
141
|
+
assertName(code, `${name}.code`, 128);
|
|
142
|
+
this.code = code;
|
|
143
|
+
if (details !== undefined) {
|
|
144
|
+
this.details = boundedJson(details, `${name}.details`, MAX_REACTION_FAILURE_DETAILS_BYTES);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** A handler failure that should be retried until its attempt limit. */
|
|
149
|
+
export class RetryableReactionError extends ReactionDeliveryError {
|
|
150
|
+
constructor(code, details) {
|
|
151
|
+
super('RetryableReactionError', code, details);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** A handler failure that should be dead-lettered immediately. */
|
|
155
|
+
export class PermanentReactionError extends ReactionDeliveryError {
|
|
156
|
+
constructor(code, details) {
|
|
157
|
+
super('PermanentReactionError', code, details);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function requiredLifecycle(storage) {
|
|
161
|
+
if (storage.claimReactions === undefined ||
|
|
162
|
+
storage.completeReaction === undefined ||
|
|
163
|
+
storage.extendReactionLease === undefined ||
|
|
164
|
+
storage.failReaction === undefined) {
|
|
165
|
+
throw new Error('storage does not implement durable reaction delivery');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Host-driven worker. Call `runOnce` from the host scheduler or queue wake. */
|
|
169
|
+
export class ReactionRunner {
|
|
170
|
+
#options;
|
|
171
|
+
#types;
|
|
172
|
+
#clock;
|
|
173
|
+
#leaseDurationMs;
|
|
174
|
+
#batchSize;
|
|
175
|
+
#initialBackoffMs;
|
|
176
|
+
#maxBackoffMs;
|
|
177
|
+
constructor(options) {
|
|
178
|
+
requiredLifecycle(options.storage);
|
|
179
|
+
assertName(options.workerId, 'workerId', 128);
|
|
180
|
+
this.#types = Object.keys(options.handlers).sort();
|
|
181
|
+
if (this.#types.length === 0 || this.#types.length > 64) {
|
|
182
|
+
throw new Error('reaction runner requires from 1 through 64 handlers');
|
|
183
|
+
}
|
|
184
|
+
for (const type of this.#types)
|
|
185
|
+
assertName(type, 'handler type', 128);
|
|
186
|
+
this.#leaseDurationMs =
|
|
187
|
+
options.leaseDurationMs ?? DEFAULT_REACTION_LEASE_MS;
|
|
188
|
+
this.#batchSize = options.batchSize ?? 10;
|
|
189
|
+
this.#initialBackoffMs =
|
|
190
|
+
options.initialBackoffMs ?? DEFAULT_REACTION_INITIAL_BACKOFF_MS;
|
|
191
|
+
this.#maxBackoffMs =
|
|
192
|
+
options.maxBackoffMs ?? DEFAULT_REACTION_MAX_BACKOFF_MS;
|
|
193
|
+
for (const [name, value] of [
|
|
194
|
+
['leaseDurationMs', this.#leaseDurationMs],
|
|
195
|
+
['batchSize', this.#batchSize],
|
|
196
|
+
['initialBackoffMs', this.#initialBackoffMs],
|
|
197
|
+
['maxBackoffMs', this.#maxBackoffMs],
|
|
198
|
+
]) {
|
|
199
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
200
|
+
throw new Error(`${name} must be a positive safe integer`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (this.#batchSize > 100)
|
|
204
|
+
throw new Error('batchSize cannot exceed 100');
|
|
205
|
+
if (this.#initialBackoffMs > this.#maxBackoffMs) {
|
|
206
|
+
throw new Error('initialBackoffMs cannot exceed maxBackoffMs');
|
|
207
|
+
}
|
|
208
|
+
this.#options = options;
|
|
209
|
+
this.#clock = options.clock ?? Date.now;
|
|
210
|
+
}
|
|
211
|
+
async runOnce() {
|
|
212
|
+
const claim = this.#options.storage.claimReactions;
|
|
213
|
+
const complete = this.#options.storage.completeReaction;
|
|
214
|
+
const extend = this.#options.storage.extendReactionLease;
|
|
215
|
+
const fail = this.#options.storage.failReaction;
|
|
216
|
+
if (claim === undefined ||
|
|
217
|
+
complete === undefined ||
|
|
218
|
+
extend === undefined ||
|
|
219
|
+
fail === undefined) {
|
|
220
|
+
throw new Error('storage lost durable reaction delivery support');
|
|
221
|
+
}
|
|
222
|
+
const leaseOwner = `${this.#options.workerId}:${crypto.randomUUID()}`;
|
|
223
|
+
const reactions = await claim.call(this.#options.storage, this.#options.partition, {
|
|
224
|
+
leaseOwner,
|
|
225
|
+
types: this.#types,
|
|
226
|
+
nowMs: this.#clock(),
|
|
227
|
+
leaseDurationMs: this.#leaseDurationMs,
|
|
228
|
+
limit: this.#batchSize,
|
|
229
|
+
});
|
|
230
|
+
let completed = 0;
|
|
231
|
+
let retried = 0;
|
|
232
|
+
let deadLettered = 0;
|
|
233
|
+
let lostLeases = 0;
|
|
234
|
+
for (const reaction of reactions) {
|
|
235
|
+
const events = this.#options.events;
|
|
236
|
+
const startedAtMs = this.#clock();
|
|
237
|
+
const stillOwned = await extend.call(this.#options.storage, this.#options.partition, reaction.idempotencyKey, leaseOwner, Math.min(Number.MAX_SAFE_INTEGER, startedAtMs + this.#leaseDurationMs));
|
|
238
|
+
if (!stillOwned) {
|
|
239
|
+
lostLeases += 1;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (events !== undefined) {
|
|
243
|
+
emitEvent(events, {
|
|
244
|
+
type: 'reaction.started',
|
|
245
|
+
atMs: startedAtMs,
|
|
246
|
+
partition: this.#options.partition,
|
|
247
|
+
workerId: this.#options.workerId,
|
|
248
|
+
idempotencyKey: reaction.idempotencyKey,
|
|
249
|
+
reactionType: reaction.type,
|
|
250
|
+
version: reaction.version,
|
|
251
|
+
attempt: reaction.attempts,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
let handlerFailed = false;
|
|
255
|
+
let handlerError;
|
|
256
|
+
try {
|
|
257
|
+
const handler = this.#options.handlers[reaction.type];
|
|
258
|
+
if (handler === undefined) {
|
|
259
|
+
throw new PermanentReactionError('reaction.handler_missing');
|
|
260
|
+
}
|
|
261
|
+
await handler({
|
|
262
|
+
partition: this.#options.partition,
|
|
263
|
+
idempotencyKey: reaction.idempotencyKey,
|
|
264
|
+
type: reaction.type,
|
|
265
|
+
version: reaction.version,
|
|
266
|
+
payload: reaction.payload,
|
|
267
|
+
attempt: reaction.attempts,
|
|
268
|
+
maxAttempts: reaction.maxAttempts,
|
|
269
|
+
sourceClientId: reaction.sourceClientId,
|
|
270
|
+
sourceClientCommitId: reaction.sourceClientCommitId,
|
|
271
|
+
sourceCommitSeq: reaction.sourceCommitSeq,
|
|
272
|
+
extendLease: async () => {
|
|
273
|
+
const renewed = await extend.call(this.#options.storage, this.#options.partition, reaction.idempotencyKey, leaseOwner, Math.min(Number.MAX_SAFE_INTEGER, this.#clock() + this.#leaseDurationMs));
|
|
274
|
+
if (!renewed)
|
|
275
|
+
throw new Error('reaction lease ownership lost');
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
handlerFailed = true;
|
|
281
|
+
handlerError = error;
|
|
282
|
+
}
|
|
283
|
+
if (!handlerFailed) {
|
|
284
|
+
const atMs = this.#clock();
|
|
285
|
+
const acknowledged = await complete.call(this.#options.storage, this.#options.partition, reaction.idempotencyKey, leaseOwner, atMs);
|
|
286
|
+
if (!acknowledged) {
|
|
287
|
+
lostLeases += 1;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
completed += 1;
|
|
291
|
+
if (events !== undefined) {
|
|
292
|
+
emitEvent(events, {
|
|
293
|
+
type: 'reaction.completed',
|
|
294
|
+
atMs,
|
|
295
|
+
partition: this.#options.partition,
|
|
296
|
+
workerId: this.#options.workerId,
|
|
297
|
+
idempotencyKey: reaction.idempotencyKey,
|
|
298
|
+
reactionType: reaction.type,
|
|
299
|
+
version: reaction.version,
|
|
300
|
+
attempt: reaction.attempts,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const atMs = this.#clock();
|
|
306
|
+
const permanent = handlerError instanceof PermanentReactionError;
|
|
307
|
+
const failure = {
|
|
308
|
+
code: handlerError instanceof ReactionDeliveryError
|
|
309
|
+
? handlerError.code
|
|
310
|
+
: 'reaction.handler_failed',
|
|
311
|
+
atMs,
|
|
312
|
+
...(handlerError instanceof ReactionDeliveryError &&
|
|
313
|
+
handlerError.details !== undefined
|
|
314
|
+
? { details: handlerError.details }
|
|
315
|
+
: {}),
|
|
316
|
+
};
|
|
317
|
+
const deadLetter = permanent || reaction.attempts >= reaction.maxAttempts;
|
|
318
|
+
const retryAtMs = deadLetter
|
|
319
|
+
? undefined
|
|
320
|
+
: Math.min(Number.MAX_SAFE_INTEGER, atMs +
|
|
321
|
+
Math.min(this.#maxBackoffMs, this.#initialBackoffMs *
|
|
322
|
+
2 ** Math.min(30, reaction.attempts - 1)));
|
|
323
|
+
const recorded = await fail.call(this.#options.storage, this.#options.partition, reaction.idempotencyKey, {
|
|
324
|
+
leaseOwner,
|
|
325
|
+
failure,
|
|
326
|
+
...(retryAtMs !== undefined ? { retryAtMs } : {}),
|
|
327
|
+
});
|
|
328
|
+
if (!recorded) {
|
|
329
|
+
lostLeases += 1;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (retryAtMs !== undefined) {
|
|
333
|
+
retried += 1;
|
|
334
|
+
if (events !== undefined) {
|
|
335
|
+
emitEvent(events, {
|
|
336
|
+
type: 'reaction.retried',
|
|
337
|
+
atMs,
|
|
338
|
+
partition: this.#options.partition,
|
|
339
|
+
workerId: this.#options.workerId,
|
|
340
|
+
idempotencyKey: reaction.idempotencyKey,
|
|
341
|
+
reactionType: reaction.type,
|
|
342
|
+
version: reaction.version,
|
|
343
|
+
attempt: reaction.attempts,
|
|
344
|
+
nextAttemptAtMs: retryAtMs,
|
|
345
|
+
errorCode: failure.code,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
deadLettered += 1;
|
|
351
|
+
if (events !== undefined) {
|
|
352
|
+
emitEvent(events, {
|
|
353
|
+
type: 'reaction.dead_lettered',
|
|
354
|
+
atMs,
|
|
355
|
+
partition: this.#options.partition,
|
|
356
|
+
workerId: this.#options.workerId,
|
|
357
|
+
idempotencyKey: reaction.idempotencyKey,
|
|
358
|
+
reactionType: reaction.type,
|
|
359
|
+
version: reaction.version,
|
|
360
|
+
attempt: reaction.attempts,
|
|
361
|
+
errorCode: failure.code,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
claimed: reactions.length,
|
|
368
|
+
completed,
|
|
369
|
+
retried,
|
|
370
|
+
deadLettered,
|
|
371
|
+
lostLeases,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/** Explicit operator action for a dead-lettered reaction. */
|
|
376
|
+
export async function retryDeadLetterReaction(options) {
|
|
377
|
+
if (options.storage.retryReaction === undefined) {
|
|
378
|
+
throw new Error('storage does not implement durable reaction retry');
|
|
379
|
+
}
|
|
380
|
+
return options.storage.retryReaction(options.partition, options.idempotencyKey, options.nowMs ?? Date.now());
|
|
381
|
+
}
|
|
382
|
+
/** Delete one bounded batch of aged completed and dead-lettered rows. */
|
|
383
|
+
export async function pruneReactions(options) {
|
|
384
|
+
const retention = {
|
|
385
|
+
...DEFAULT_REACTION_RETENTION,
|
|
386
|
+
...options.retention,
|
|
387
|
+
};
|
|
388
|
+
if (!Number.isSafeInteger(options.nowMs)) {
|
|
389
|
+
throw new Error('nowMs must be a safe integer');
|
|
390
|
+
}
|
|
391
|
+
for (const [name, value] of [
|
|
392
|
+
['completedRetentionMs', retention.completedRetentionMs],
|
|
393
|
+
['deadLetterRetentionMs', retention.deadLetterRetentionMs],
|
|
394
|
+
]) {
|
|
395
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
396
|
+
throw new Error(`${name} must be a non-negative safe integer`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (!Number.isSafeInteger(retention.batchSize) ||
|
|
400
|
+
retention.batchSize < 1 ||
|
|
401
|
+
retention.batchSize > 10_000) {
|
|
402
|
+
throw new Error('batchSize must be from 1 through 10000');
|
|
403
|
+
}
|
|
404
|
+
const prune = options.storage.pruneReactions;
|
|
405
|
+
if (prune === undefined) {
|
|
406
|
+
throw new Error('storage does not implement durable reaction pruning');
|
|
407
|
+
}
|
|
408
|
+
const completedBeforeMs = Math.max(Number.MIN_SAFE_INTEGER, options.nowMs - retention.completedRetentionMs);
|
|
409
|
+
const deadLetterBeforeMs = Math.max(Number.MIN_SAFE_INTEGER, options.nowMs - retention.deadLetterRetentionMs);
|
|
410
|
+
const removed = await prune.call(options.storage, options.partition, {
|
|
411
|
+
completedBeforeMs,
|
|
412
|
+
deadLetterBeforeMs,
|
|
413
|
+
limit: retention.batchSize,
|
|
414
|
+
});
|
|
415
|
+
const result = {
|
|
416
|
+
completedBeforeMs,
|
|
417
|
+
deadLetterBeforeMs,
|
|
418
|
+
removedCompleted: removed.completed,
|
|
419
|
+
removedDeadLetter: removed.deadLetter,
|
|
420
|
+
mayHaveMore: removed.completed + removed.deadLetter === retention.batchSize,
|
|
421
|
+
};
|
|
422
|
+
if (options.events !== undefined) {
|
|
423
|
+
emitEvent(options.events, {
|
|
424
|
+
type: 'reaction.prune_completed',
|
|
425
|
+
atMs: options.nowMs,
|
|
426
|
+
partition: options.partition,
|
|
427
|
+
limit: retention.batchSize,
|
|
428
|
+
...result,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return result;
|
|
432
|
+
}
|
|
433
|
+
/** Helper used by the push path after commit sequence allocation. */
|
|
434
|
+
export function toNewReactions(prepared, source) {
|
|
435
|
+
return prepared.map((reaction) => ({
|
|
436
|
+
...reaction,
|
|
437
|
+
sourceClientId: source.clientId,
|
|
438
|
+
sourceClientCommitId: source.clientCommitId,
|
|
439
|
+
sourceCommitSeq: source.commitSeq,
|
|
440
|
+
createdAtMs: source.createdAtMs,
|
|
441
|
+
}));
|
|
442
|
+
}
|
package/dist/realtime.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* the `sync` wake-up (§8.3).
|
|
15
15
|
*/
|
|
16
16
|
import { DecodeError, decodeMessage, encodeMessage, encodePresenceError, encodePresenceFanout, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimePresencePublish, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
|
|
17
|
-
import { RESOLVER_OUTAGE } from './context.js';
|
|
17
|
+
import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE } from './context.js';
|
|
18
18
|
import { SyncError, syncError } from './errors.js';
|
|
19
19
|
import { emitEvent } from './events.js';
|
|
20
20
|
import { createSyncResponseStream } from './handler.js';
|
|
@@ -770,6 +770,9 @@ export class RealtimeHub {
|
|
|
770
770
|
async connect(options) {
|
|
771
771
|
const { storage } = this.#config;
|
|
772
772
|
const clock = this.#config.clock ?? Date.now;
|
|
773
|
+
if (options.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
|
|
774
|
+
throw syncError('sync.invalid_client_id', 'clientId uses a reserved server-command namespace (§1.5)');
|
|
775
|
+
}
|
|
773
776
|
const record = await storage.getClientRecord(options.partition, options.clientId);
|
|
774
777
|
if (record !== undefined && record.actorId !== options.actorId) {
|
|
775
778
|
throw syncError('sync.invalid_client_id', 'clientId is bound to a different actor in this partition (§1.5)');
|
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SQLite-backed blob store
|
|
3
|
-
* dependency-free). Bun-specific by design (top-level `bun:sqlite` import),
|
|
4
|
-
* so it lives in its own module — the runtime-neutral `BlobStore` interface,
|
|
5
|
-
* `MemoryBlobStore`, `blobIdFor`, and `isBlobId` stay in `blob-store.ts` for
|
|
6
|
-
* the Workers/edge core (runtime neutrality is enforced by
|
|
7
|
-
* `test/runtime-neutrality.test.ts`).
|
|
2
|
+
* SQLite-backed blob store over the shared synchronous driver.
|
|
8
3
|
*/
|
|
9
|
-
import { Database } from 'bun:sqlite';
|
|
10
4
|
import type { BlobRecord, BlobStore, BlobStoreStats } from './blob-store.js';
|
|
5
|
+
import { type SqliteDatabase } from './sqlite-driver.js';
|
|
11
6
|
export declare class SqliteBlobStore implements BlobStore {
|
|
12
|
-
readonly db:
|
|
13
|
-
constructor(db?:
|
|
7
|
+
readonly db: SqliteDatabase;
|
|
8
|
+
constructor(db?: SqliteDatabase | string);
|
|
14
9
|
put(partition: string, blobId: string, bytes: Uint8Array, nowMs: number, mediaType?: string): Promise<BlobRecord>;
|
|
15
10
|
has(partition: string, blobId: string): Promise<boolean>;
|
|
16
11
|
get(partition: string, blobId: string): Promise<{
|
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
* SQLite-backed blob store via `bun:sqlite` (dev/bench convenience,
|
|
3
|
-
* dependency-free). Bun-specific by design (top-level `bun:sqlite` import),
|
|
4
|
-
* so it lives in its own module — the runtime-neutral `BlobStore` interface,
|
|
5
|
-
* `MemoryBlobStore`, `blobIdFor`, and `isBlobId` stay in `blob-store.ts` for
|
|
6
|
-
* the Workers/edge core (runtime neutrality is enforced by
|
|
7
|
-
* `test/runtime-neutrality.test.ts`).
|
|
8
|
-
*/
|
|
9
|
-
import { Database } from 'bun:sqlite';
|
|
1
|
+
import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
|
|
10
2
|
export class SqliteBlobStore {
|
|
11
3
|
db;
|
|
12
4
|
constructor(db = ':memory:') {
|
|
13
|
-
|
|
5
|
+
if (typeof db === 'string') {
|
|
6
|
+
throw new SqliteAdapterRequiredError();
|
|
7
|
+
}
|
|
8
|
+
this.db = db;
|
|
14
9
|
this.db.exec(`
|
|
15
10
|
CREATE TABLE IF NOT EXISTS sync_blobs(
|
|
16
11
|
partition TEXT NOT NULL, blob_id TEXT NOT NULL,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import type { SqliteDatabase, SqliteRunResult, SqliteStatement, SqliteValue } from './sqlite-driver.js';
|
|
3
|
+
export declare class BunSqliteDatabase implements SqliteDatabase {
|
|
4
|
+
readonly native: Database;
|
|
5
|
+
constructor(path?: string);
|
|
6
|
+
exec(sql: string): void;
|
|
7
|
+
run(sql: string, bindings?: readonly SqliteValue[]): SqliteRunResult;
|
|
8
|
+
query<Row, Params extends readonly SqliteValue[]>(sql: string): SqliteStatement<Row, Params>;
|
|
9
|
+
serialize(): Uint8Array;
|
|
10
|
+
close(): void;
|
|
11
|
+
}
|