@context-use/open-sync 0.1.0

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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +44 -0
  3. package/src/api.ts +53 -0
  4. package/src/build.ts +6 -0
  5. package/src/connector/client.ts +156 -0
  6. package/src/connector/encryption-key.ts +23 -0
  7. package/src/connector/management.ts +76 -0
  8. package/src/connector/response.ts +35 -0
  9. package/src/db/client.ts +24 -0
  10. package/src/db/providers.sql +15 -0
  11. package/src/db/providers.sql.d.ts +2 -0
  12. package/src/db/providers.ts +24 -0
  13. package/src/db/schema.sql +45 -0
  14. package/src/db/schema.sql.d.ts +2 -0
  15. package/src/execution/diagnostics.ts +18 -0
  16. package/src/execution/provider.ts +37 -0
  17. package/src/execution/worker.ts +90 -0
  18. package/src/http/app.ts +21 -0
  19. package/src/http/controller.ts +95 -0
  20. package/src/http/index.ts +20 -0
  21. package/src/http/providers.ts +94 -0
  22. package/src/models/definition.ts +63 -0
  23. package/src/models/delivery-result.ts +22 -0
  24. package/src/models/delivery.ts +73 -0
  25. package/src/models/error.ts +11 -0
  26. package/src/models/identity.ts +11 -0
  27. package/src/models/installation.ts +37 -0
  28. package/src/models/json.ts +82 -0
  29. package/src/models/limits.ts +42 -0
  30. package/src/models/page.ts +69 -0
  31. package/src/models/provider-values.ts +42 -0
  32. package/src/models/providers.ts +34 -0
  33. package/src/models/registry.ts +74 -0
  34. package/src/models/validation.ts +24 -0
  35. package/src/open-sync.ts +182 -0
  36. package/src/repositories/acquisition/contract.ts +17 -0
  37. package/src/repositories/acquisition/lease.ts +118 -0
  38. package/src/repositories/acquisition/outbox.ts +52 -0
  39. package/src/repositories/acquisition/records.ts +37 -0
  40. package/src/repositories/acquisition/sqlite.ts +60 -0
  41. package/src/repositories/catalog/contract.ts +23 -0
  42. package/src/repositories/catalog/sqlite.ts +166 -0
  43. package/src/repositories/delivery/contract.ts +23 -0
  44. package/src/repositories/delivery/sqlite.ts +111 -0
  45. package/src/repositories/providers/contract.ts +15 -0
  46. package/src/repositories/providers/sqlite.ts +79 -0
  47. package/src/repositories/queue-usage.ts +21 -0
  48. package/src/repositories/rows.ts +50 -0
  49. package/src/runtime.ts +90 -0
  50. package/src/services/acquisition.ts +100 -0
  51. package/src/services/delivery.ts +53 -0
  52. package/src/services/management.ts +124 -0
  53. package/src/services/providers/service.ts +190 -0
@@ -0,0 +1,90 @@
1
+ import { fail } from '../models/error';
2
+ import type { Resource } from '../models/identity';
3
+ import type { Timing } from '../models/limits';
4
+ import type { AcquisitionService } from '../services/acquisition';
5
+ import type { DeliveryService } from '../services/delivery';
6
+ import type { Logger } from './diagnostics';
7
+
8
+ export interface WorkerControl {
9
+ ensureOpen(): void;
10
+ cancel(input: Resource): Promise<void>;
11
+ }
12
+ export class Worker implements WorkerControl {
13
+ readonly #lifetime = new AbortController();
14
+ #timer?: ReturnType<typeof setInterval>;
15
+ #acquisition?: Promise<void>;
16
+ #delivery?: Promise<void>;
17
+ #active?: { ownerId: string; id: string; abort: AbortController };
18
+ #closing?: Promise<void>;
19
+ constructor(
20
+ private readonly input: {
21
+ acquisition: AcquisitionService;
22
+ delivery: DeliveryService;
23
+ timing: Timing;
24
+ log: Logger;
25
+ },
26
+ ) {}
27
+ ensureOpen(): void {
28
+ if (this.#lifetime.signal.aborted) {
29
+ fail('closed');
30
+ }
31
+ }
32
+ start(): void {
33
+ this.ensureOpen();
34
+ if (this.#timer) {
35
+ return;
36
+ }
37
+ const pump = () => {
38
+ void this.tick().catch(() => this.input.log({ code: 'runtime_failed' }));
39
+ };
40
+ this.#timer = setInterval(pump, this.input.timing.pollMs);
41
+ pump();
42
+ }
43
+ tick(): Promise<void> {
44
+ this.ensureOpen();
45
+ this.#acquisition ??= this.acquire().finally(() => {
46
+ this.#acquisition = undefined;
47
+ });
48
+ this.#delivery ??= this.input.delivery.execute(this.signal()).finally(() => {
49
+ this.#delivery = undefined;
50
+ });
51
+ return Promise.all([this.#acquisition, this.#delivery]).then(() => undefined);
52
+ }
53
+ private signal(extra?: AbortSignal): AbortSignal {
54
+ return AbortSignal.any([
55
+ this.#lifetime.signal,
56
+ AbortSignal.timeout(this.input.timing.timeoutMs),
57
+ ...(extra ? [extra] : []),
58
+ ]);
59
+ }
60
+ private async acquire(): Promise<void> {
61
+ const lease = this.input.acquisition.claim();
62
+ if (!lease) {
63
+ return;
64
+ }
65
+ const abort = new AbortController();
66
+ this.#active = { ownerId: lease.ownerId, id: lease.installation.id, abort };
67
+ try {
68
+ await this.input.acquisition.execute({ lease, signal: this.signal(abort.signal) });
69
+ } finally {
70
+ this.#active = undefined;
71
+ }
72
+ }
73
+ async cancel(input: Resource): Promise<void> {
74
+ if (this.#active?.ownerId === input.ownerId && this.#active.id === input.id) {
75
+ this.#active.abort.abort();
76
+ await this.#acquisition;
77
+ }
78
+ }
79
+ close(): Promise<void> {
80
+ this.#closing ??= this.stop();
81
+ return this.#closing;
82
+ }
83
+ private async stop(): Promise<void> {
84
+ if (this.#timer) {
85
+ clearInterval(this.#timer);
86
+ }
87
+ this.#lifetime.abort();
88
+ await Promise.allSettled([this.#acquisition, this.#delivery]);
89
+ }
90
+ }
@@ -0,0 +1,21 @@
1
+ import { Elysia } from 'elysia';
2
+ import type { SyncApi } from '../api';
3
+ import type { Scope } from '../models/identity';
4
+ import type { ProviderService } from '../services/providers/service';
5
+ import { createSyncController } from './controller';
6
+ import { syncErrorResponse } from './index';
7
+ import { createProviderController } from './providers';
8
+
9
+ export function createHttpApp<Prefix extends string>(input: {
10
+ prefix: Prefix;
11
+ api: SyncApi;
12
+ providers: ProviderService;
13
+ authorize(request: Request): Scope | null | Promise<Scope | null>;
14
+ authorizationRedirect?(input: { service: string; outcome: 'connected' | 'failed' }): string;
15
+ }) {
16
+ return new Elysia({ prefix: input.prefix })
17
+ .onError(({ error }) => syncErrorResponse(error))
18
+ .use(createSyncController(input))
19
+ .use(createProviderController(input));
20
+ }
21
+ export type OpenSyncHttp<Prefix extends string = ''> = ReturnType<typeof createHttpApp<Prefix>>;
@@ -0,0 +1,95 @@
1
+ import { Elysia, t } from 'elysia';
2
+ import type { SyncApi } from '../api';
3
+ import type { Scope } from '../models/identity';
4
+ import type { JsonObject } from '../models/json';
5
+ import { syncErrorResponse } from './index';
6
+
7
+ const identifier = t.String({ minLength: 1, maxLength: 1024 });
8
+ const config = t.Record(t.String(), t.Unknown());
9
+ const definition = t.Object({ id: identifier, version: identifier, artifactId: identifier });
10
+ const installationBody = t.Object({
11
+ definition,
12
+ config,
13
+ destinationId: identifier,
14
+ connection: t.Optional(t.Object({ id: identifier, service: identifier })),
15
+ intervalMs: t.Optional(t.Integer({ minimum: 1 })),
16
+ enabled: t.Optional(t.Boolean()),
17
+ });
18
+ const resourceParams = { params: t.Object({ id: identifier }) };
19
+ /** Optional management transport. The host owns authentication, owner authorization and CSRF policy. */
20
+ export function createSyncController(input: {
21
+ api: SyncApi;
22
+ authorize(request: Request): Scope | null | Promise<Scope | null>;
23
+ }) {
24
+ return new Elysia({ prefix: '/sync' })
25
+ .onError(({ error }) => syncErrorResponse(error))
26
+ .resolve(async ({ request, status }) => {
27
+ const scope = await input.authorize(request);
28
+ if (!scope) {
29
+ return status('Unauthorized', { error: 'Unauthorized' });
30
+ }
31
+ return { scope };
32
+ })
33
+ .get('/definitions', ({ scope }) => ({ definitions: input.api.definitions(scope) }))
34
+ .get('/destination-types', ({ scope }) => ({ types: input.api.destinationTypes(scope) }))
35
+ .get('/destinations', ({ scope }) => ({ destinations: input.api.destinations(scope) }))
36
+ .post(
37
+ '/destinations',
38
+ ({ scope, body }) =>
39
+ input.api.createDestination({ ...body, config: body.config as JsonObject, ...scope }),
40
+ {
41
+ body: t.Object({ type: identifier, config }),
42
+ },
43
+ )
44
+ .get('/installations', ({ scope }) => ({ installations: input.api.installations(scope) }))
45
+ .post(
46
+ '/installations',
47
+ ({ scope, body }) =>
48
+ input.api.createInstallation({ ...body, config: body.config as JsonObject, ...scope }),
49
+ { body: installationBody },
50
+ )
51
+ .get(
52
+ '/installations/:id',
53
+ ({ scope, params }) => input.api.installation({ ...scope, id: params.id }),
54
+ resourceParams,
55
+ )
56
+ .get(
57
+ '/installations/:id/runs',
58
+ ({ scope, params, query }) =>
59
+ input.api.runs({ ...scope, id: params.id, offset: query.offset }),
60
+ {
61
+ ...resourceParams,
62
+ query: t.Object({ offset: t.Optional(t.Integer({ minimum: 0, maximum: 1000000 })) }),
63
+ },
64
+ )
65
+ .patch(
66
+ '/installations/:id',
67
+ ({ scope, params, body }) =>
68
+ input.api.setEnabled({ ...scope, id: params.id, enabled: body.enabled }),
69
+ { ...resourceParams, body: t.Object({ enabled: t.Boolean() }) },
70
+ )
71
+ .post(
72
+ '/installations/:id/run',
73
+ ({ scope, params, body }) => {
74
+ input.api.queueRun({ ...scope, id: params.id, backfill: body.backfill });
75
+ return { queued: true };
76
+ },
77
+ { ...resourceParams, body: t.Object({ backfill: t.Optional(t.Boolean()) }) },
78
+ )
79
+ .get('/status', ({ scope }) => input.api.status(scope))
80
+ .get(
81
+ '/deliveries',
82
+ ({ scope, query }) => input.api.deliveries({ ...scope, offset: query.offset }),
83
+ {
84
+ query: t.Object({ offset: t.Optional(t.Integer({ minimum: 0, maximum: 1000000 })) }),
85
+ },
86
+ )
87
+ .post(
88
+ '/deliveries/:id/retry',
89
+ ({ scope, params }) => {
90
+ input.api.retryDelivery({ ...scope, id: params.id });
91
+ return { queued: true };
92
+ },
93
+ resourceParams,
94
+ );
95
+ }
@@ -0,0 +1,20 @@
1
+ import { status } from 'elysia';
2
+ import { SyncError } from '../models/error';
3
+
4
+ export type { OpenSyncHttp } from './app';
5
+
6
+ const notFound = 404;
7
+ const conflict = 409;
8
+ const unauthorized = 401;
9
+ const forbidden = 403;
10
+ const invalidInput = 400;
11
+ export function syncErrorResponse(error: unknown) {
12
+ if (error instanceof SyncError) {
13
+ return status(
14
+ ({ not_found: notFound, busy: conflict, unauthorized, forbidden } as Record<string, number>)[
15
+ error.code
16
+ ] ?? invalidInput,
17
+ { error: error.code },
18
+ );
19
+ }
20
+ }
@@ -0,0 +1,94 @@
1
+ import { Elysia, t } from 'elysia';
2
+ import { fail, SyncError } from '../models/error';
3
+ import type { Scope } from '../models/identity';
4
+ import type { ProviderService } from '../services/providers/service';
5
+ import { syncErrorResponse } from './index';
6
+
7
+ export function createProviderController(input: {
8
+ providers: ProviderService;
9
+ authorizationRedirect?(input: { service: string; outcome: 'connected' | 'failed' }): string;
10
+ authorize(request: Request): Scope | null | Promise<Scope | null>;
11
+ }) {
12
+ async function scope(request: Request) {
13
+ const principal = await input.authorize(request);
14
+ if (!principal) {
15
+ fail('unauthorized');
16
+ }
17
+ return principal;
18
+ }
19
+ const service = t.String({ pattern: '^[a-zA-Z0-9_-]+$', maxLength: 128 });
20
+ const id = t.String({ pattern: '^connection_[a-f0-9-]{36}$' });
21
+ const values = t.Record(t.String({ maxLength: 128 }), t.String({ maxLength: 65536 }), {
22
+ maxProperties: 64,
23
+ });
24
+ return new Elysia({ prefix: '/providers' })
25
+ .onError(({ error }) => syncErrorResponse(error))
26
+ .get(
27
+ '/connections',
28
+ async ({ request }) => await input.providers.connections(await scope(request)),
29
+ )
30
+ .get('/', async ({ request }) => await input.providers.catalog(await scope(request)))
31
+ .get(
32
+ '/:service',
33
+ async ({ request, params }) =>
34
+ await input.providers.status({ ...(await scope(request)), ...params }),
35
+ { params: t.Object({ service }) },
36
+ )
37
+ .put(
38
+ '/:service/oauth-client',
39
+ async ({ request, params, body }) =>
40
+ await input.providers.configure({ ...(await scope(request)), ...params, ...body }),
41
+ {
42
+ params: t.Object({ service }),
43
+ body: t.Object({ values }),
44
+ },
45
+ )
46
+ .post(
47
+ '/:service/connect',
48
+ async ({ request, params, body }) =>
49
+ await input.providers.start({ ...(await scope(request)), ...params, ...body }),
50
+ {
51
+ params: t.Object({ service }),
52
+ body: t.Object({
53
+ authorizationOptionIds: t.Optional(
54
+ t.Array(t.String({ maxLength: 256 }), { maxItems: 128 }),
55
+ ),
56
+ }),
57
+ },
58
+ )
59
+ .post(
60
+ '/:service/credentials',
61
+ async ({ request, params, body }) =>
62
+ await input.providers.credentials({ ...(await scope(request)), ...params, ...body }),
63
+ {
64
+ params: t.Object({ service }),
65
+ body: t.Object({
66
+ authType: t.Union([t.Literal('api_key'), t.Literal('custom_credential')]),
67
+ values,
68
+ }),
69
+ },
70
+ )
71
+ .get(
72
+ '/:service/return/:id',
73
+ async ({ request, params, redirect }) => {
74
+ try {
75
+ await input.providers.complete({ ...(await scope(request)), ...params });
76
+ } catch (error) {
77
+ if (
78
+ !(error instanceof SyncError) ||
79
+ error.code !== 'provider_request_failed' ||
80
+ !input.authorizationRedirect
81
+ ) {
82
+ throw error;
83
+ }
84
+ return redirect(
85
+ input.authorizationRedirect({ service: params.service, outcome: 'failed' }),
86
+ );
87
+ }
88
+ return input.authorizationRedirect
89
+ ? redirect(input.authorizationRedirect({ service: params.service, outcome: 'connected' }))
90
+ : { connected: true };
91
+ },
92
+ { params: t.Object({ service, id }) },
93
+ );
94
+ }
@@ -0,0 +1,63 @@
1
+ import type { Schema } from '@cfworker/json-schema';
2
+ import type { Deliverable } from './delivery';
3
+ import type { JsonObject, JsonValue } from './json';
4
+
5
+ export interface DefinitionRef {
6
+ id: string;
7
+ version: string;
8
+ artifactId: string;
9
+ }
10
+ export interface ConnectionRef {
11
+ id: string;
12
+ service: string;
13
+ }
14
+ export interface ProviderRequirements {
15
+ service: string;
16
+ actions: readonly string[];
17
+ proxyPaths?: readonly string[];
18
+ proxyPostPaths?: readonly string[];
19
+ }
20
+ /** JSON or text response from the provider, including non-success HTTP statuses. */
21
+ export interface ProviderResponse {
22
+ status: number;
23
+ headers: Readonly<Record<string, string>>;
24
+ body: JsonValue;
25
+ }
26
+ export interface ProviderOperations {
27
+ action(input: { id: string; input: JsonObject }): Promise<JsonValue>;
28
+ get(input: { path: string; query?: JsonObject }): Promise<ProviderResponse>;
29
+ post(input: { path: string; body: JsonObject }): Promise<ProviderResponse>;
30
+ }
31
+ export interface SyncDefinition extends DefinitionRef {
32
+ name?: string;
33
+ description?: string;
34
+ configSchema: Schema;
35
+ checkpointSchema: Schema;
36
+ initialCheckpoint: JsonValue;
37
+ kinds: Readonly<Record<string, Schema>>;
38
+ provider?: ProviderRequirements;
39
+ }
40
+ export interface SyncPage {
41
+ deliverable: Deliverable;
42
+ checkpoint: JsonValue;
43
+ complete: boolean;
44
+ }
45
+ export interface SyncContext {
46
+ config: JsonObject;
47
+ checkpoint: JsonValue;
48
+ sourceId: string;
49
+ signal: AbortSignal;
50
+ provider: ProviderOperations;
51
+ log(input: { message: string; fields?: JsonObject }): void;
52
+ }
53
+ export interface SyncExecutable {
54
+ run(context: SyncContext): AsyncIterable<SyncPage>;
55
+ }
56
+ /** Trusted host code only. Loading uploaded code requires a separate isolated execution layer. */
57
+ export interface SyncRegistration {
58
+ definition: SyncDefinition;
59
+ load(): SyncExecutable | Promise<SyncExecutable>;
60
+ }
61
+ export function definitionKey(ref: DefinitionRef): string {
62
+ return JSON.stringify([ref.id, ref.version, ref.artifactId]);
63
+ }
@@ -0,0 +1,22 @@
1
+ import type { DeliveryResult } from './delivery';
2
+ import { fail } from './error';
3
+ import { identifier } from './validation';
4
+ export function validateResult(result: DeliveryResult): DeliveryResult {
5
+ if (!['accepted', 'retry', 'rejected'].includes(result?.status)) {
6
+ fail('invalid_delivery_result');
7
+ }
8
+ if (result.status !== 'accepted' && result.code !== undefined) {
9
+ identifier(result.code);
10
+ }
11
+ if (result.status === 'rejected' && !result.code) {
12
+ fail('invalid_delivery_result');
13
+ }
14
+ if (
15
+ result.status === 'retry' &&
16
+ result.retryAfterMs !== undefined &&
17
+ (!Number.isSafeInteger(result.retryAfterMs) || result.retryAfterMs < 0)
18
+ ) {
19
+ fail('invalid_delivery_result');
20
+ }
21
+ return result;
22
+ }
@@ -0,0 +1,73 @@
1
+ import type { Schema } from '@cfworker/json-schema';
2
+ import type { DefinitionRef } from './definition';
3
+ import type { Scope } from './identity';
4
+ import type { JsonObject } from './json';
5
+
6
+ export type SyncRecord =
7
+ | { operation: 'upsert'; kind: string; id: string; data: JsonObject }
8
+ | { operation: 'delete'; kind: string; id: string };
9
+ export interface Deliverable {
10
+ records: readonly SyncRecord[];
11
+ }
12
+ export type DeliveredRecord = SyncRecord & {
13
+ eventId: string;
14
+ revision: number;
15
+ contentHash: string;
16
+ };
17
+ export interface Delivery {
18
+ version: 1;
19
+ id: string;
20
+ ownerId: string;
21
+ sourceId: string;
22
+ installationId: string;
23
+ definition: DefinitionRef;
24
+ deliverable: { records: DeliveredRecord[] };
25
+ }
26
+ export type DeliveryResult =
27
+ | { status: 'accepted' }
28
+ | { status: 'retry'; retryAfterMs?: number; code?: string }
29
+ | { status: 'rejected'; code: string };
30
+ export interface DestinationType {
31
+ name?: string;
32
+ description?: string;
33
+ /** Pin endpoint/interpretation changes to a new version. Existing work is never rerouted. */
34
+ version: string;
35
+ configSchema: Schema;
36
+ /** Accepted means durable acceptance of the whole delivery. Receivers must tolerate retries. */
37
+ deliver(input: {
38
+ scope: Scope;
39
+ config: JsonObject;
40
+ delivery: Delivery;
41
+ signal: AbortSignal;
42
+ }): Promise<DeliveryResult>;
43
+ }
44
+ export interface Destination {
45
+ id: string;
46
+ ownerId: string;
47
+ type: string;
48
+ version: string;
49
+ config: JsonObject;
50
+ }
51
+ export interface QueueStatus {
52
+ pendingBytes: number;
53
+ pendingRecords: number;
54
+ pendingDeliveries: number;
55
+ blockedDeliveries: number;
56
+ }
57
+ export interface PendingDelivery {
58
+ id: string;
59
+ installationId: string;
60
+ destinationId: string;
61
+ state: string;
62
+ bytes: number;
63
+ recordCount: number;
64
+ attempt: number;
65
+ nextAttemptAt: number;
66
+ errorCode: string | null;
67
+ }
68
+
69
+ export interface DeliveryPage {
70
+ deliveries: PendingDelivery[];
71
+ hasMore: boolean;
72
+ pageSize: number;
73
+ }
@@ -0,0 +1,11 @@
1
+ export class SyncError extends Error {
2
+ readonly code: string;
3
+ constructor(input: { code: string; message: string }) {
4
+ super(input.message);
5
+ this.name = 'SyncError';
6
+ this.code = input.code;
7
+ }
8
+ }
9
+ export function fail(code: string): never {
10
+ throw new SyncError({ code, message: code.replaceAll('_', ' ') });
11
+ }
@@ -0,0 +1,11 @@
1
+ /** The host authenticates the actor and authorizes access to this owner. */
2
+ export interface Scope {
3
+ actorId: string;
4
+ ownerId: string;
5
+ }
6
+ export interface Resource extends Scope {
7
+ id: string;
8
+ }
9
+ export function workerScope(ownerId: string): Scope {
10
+ return { actorId: 'open-sync:worker', ownerId };
11
+ }
@@ -0,0 +1,37 @@
1
+ import type { ConnectionRef, DefinitionRef } from './definition';
2
+ import type { Scope } from './identity';
3
+ import type { JsonObject, JsonValue } from './json';
4
+
5
+ export interface Installation {
6
+ id: string;
7
+ ownerId: string;
8
+ sourceId: string;
9
+ definition: DefinitionRef;
10
+ connection?: ConnectionRef;
11
+ config: JsonObject;
12
+ destinationId: string;
13
+ enabled: boolean;
14
+ bindingEpoch: number;
15
+ checkpoint: JsonValue;
16
+ checkpointRevision: number;
17
+ intervalMs: number;
18
+ nextDueAt: number;
19
+ status: string;
20
+ }
21
+ export interface CreateInstallation extends Scope {
22
+ definition: DefinitionRef;
23
+ connection?: ConnectionRef;
24
+ config: JsonObject;
25
+ destinationId: string;
26
+ intervalMs?: number;
27
+ enabled?: boolean;
28
+ }
29
+
30
+ export interface SyncRun {
31
+ id: string;
32
+ state: string;
33
+ startedAt: number;
34
+ completedAt: number | null;
35
+ pages: number;
36
+ checkpointRevision: number;
37
+ }
@@ -0,0 +1,82 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject;
4
+ export interface JsonObject {
5
+ [key: string]: JsonValue;
6
+ }
7
+ export interface CanonicalJson {
8
+ value: JsonValue;
9
+ json: string;
10
+ sha256: string;
11
+ }
12
+ interface Visit {
13
+ value: unknown;
14
+ ancestors: Set<object>;
15
+ }
16
+
17
+ // Adapted from massimoalbarello/open-connector's src/sync/record-hash.ts.
18
+ export function canonicalJson(value: unknown): CanonicalJson {
19
+ const normalized = normalize({ value, ancestors: new Set() });
20
+ const json = JSON.stringify(normalized);
21
+ return { value: normalized, json, sha256: createHash('sha256').update(json).digest('hex') };
22
+ }
23
+ function normalize(input: Visit): JsonValue {
24
+ const { value, ancestors } = input;
25
+ if (value === null || typeof value === 'boolean' || typeof value === 'string') {
26
+ return value;
27
+ }
28
+ if (typeof value === 'number' && Number.isFinite(value)) {
29
+ return Object.is(value, -0) ? 0 : value;
30
+ }
31
+ if (typeof value !== 'object' || ancestors.has(value)) {
32
+ throw new TypeError('Invalid JSON value');
33
+ }
34
+ ancestors.add(value);
35
+ try {
36
+ return Array.isArray(value)
37
+ ? normalizeArray({ value, ancestors })
38
+ : normalizeObject({ value, ancestors });
39
+ } finally {
40
+ ancestors.delete(value);
41
+ }
42
+ }
43
+ function normalizeArray(input: { value: unknown[]; ancestors: Set<object> }): JsonValue[] {
44
+ const descriptors = Object.getOwnPropertyDescriptors(input.value);
45
+ const keys = Reflect.ownKeys(descriptors);
46
+ if (keys.length !== input.value.length + 1) {
47
+ throw new TypeError('Invalid JSON array');
48
+ }
49
+ const result: JsonValue[] = [];
50
+ for (let index = 0; index < input.value.length; index++) {
51
+ const descriptor = descriptors[index];
52
+ if (!descriptor || !('value' in descriptor)) {
53
+ throw new TypeError('Sparse or accessor array');
54
+ }
55
+ result.push(normalize({ value: descriptor.value, ancestors: input.ancestors }));
56
+ }
57
+ return result;
58
+ }
59
+ function normalizeObject(input: { value: object; ancestors: Set<object> }): JsonObject {
60
+ const prototype = Object.getPrototypeOf(input.value);
61
+ if (prototype !== Object.prototype && prototype !== null) {
62
+ throw new TypeError('Expected plain JSON');
63
+ }
64
+ if (Object.getOwnPropertySymbols(input.value).length) {
65
+ throw new TypeError('Symbol JSON property');
66
+ }
67
+ const descriptors = Object.getOwnPropertyDescriptors(input.value);
68
+ const result: JsonObject = {};
69
+ for (const key of Object.keys(descriptors).sort()) {
70
+ const descriptor = descriptors[key]!;
71
+ if (!descriptor.enumerable || !('value' in descriptor)) {
72
+ throw new TypeError('Expected data property');
73
+ }
74
+ Object.defineProperty(result, key, {
75
+ value: normalize({ value: descriptor.value, ancestors: input.ancestors }),
76
+ enumerable: true,
77
+ writable: true,
78
+ configurable: true,
79
+ });
80
+ }
81
+ return result;
82
+ }
@@ -0,0 +1,42 @@
1
+ import { fail } from './error';
2
+ export interface QueueLimits {
3
+ maxPendingBytes: number;
4
+ maxPendingRecords: number;
5
+ maxPageBytes: number;
6
+ maxPageRecords: number;
7
+ }
8
+ export const defaultLimits: QueueLimits = {
9
+ maxPendingBytes: 67_108_864,
10
+ maxPendingRecords: 100_000,
11
+ maxPageBytes: 1_048_576,
12
+ maxPageRecords: 1000,
13
+ };
14
+ export const defaultTiming = {
15
+ pollMs: 1000,
16
+ leaseMs: 60_000,
17
+ timeoutMs: 30_000,
18
+ retryMs: 30_000,
19
+ maxPages: 100,
20
+ historyLimit: 1000,
21
+ };
22
+ export type Timing = typeof defaultTiming;
23
+ export function positive(value: number): number {
24
+ if (!Number.isSafeInteger(value) || value < 1) {
25
+ fail('invalid_positive_integer');
26
+ }
27
+ return value;
28
+ }
29
+ export function retryDelay(input: {
30
+ attempt: number;
31
+ retryMs: number;
32
+ resultDelay?: number;
33
+ }): number {
34
+ const maxBackoff = 3_600_000;
35
+ const maxRequestedDelay = 86_400_000;
36
+ const maxExponent = 10;
37
+ const factor = 2;
38
+ if (input.resultDelay !== undefined) {
39
+ return Math.min(maxRequestedDelay, input.resultDelay);
40
+ }
41
+ return Math.min(maxBackoff, input.retryMs * factor ** Math.min(input.attempt - 1, maxExponent));
42
+ }