@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.
- package/LICENSE +21 -0
- package/package.json +44 -0
- package/src/api.ts +53 -0
- package/src/build.ts +6 -0
- package/src/connector/client.ts +156 -0
- package/src/connector/encryption-key.ts +23 -0
- package/src/connector/management.ts +76 -0
- package/src/connector/response.ts +35 -0
- package/src/db/client.ts +24 -0
- package/src/db/providers.sql +15 -0
- package/src/db/providers.sql.d.ts +2 -0
- package/src/db/providers.ts +24 -0
- package/src/db/schema.sql +45 -0
- package/src/db/schema.sql.d.ts +2 -0
- package/src/execution/diagnostics.ts +18 -0
- package/src/execution/provider.ts +37 -0
- package/src/execution/worker.ts +90 -0
- package/src/http/app.ts +21 -0
- package/src/http/controller.ts +95 -0
- package/src/http/index.ts +20 -0
- package/src/http/providers.ts +94 -0
- package/src/models/definition.ts +63 -0
- package/src/models/delivery-result.ts +22 -0
- package/src/models/delivery.ts +73 -0
- package/src/models/error.ts +11 -0
- package/src/models/identity.ts +11 -0
- package/src/models/installation.ts +37 -0
- package/src/models/json.ts +82 -0
- package/src/models/limits.ts +42 -0
- package/src/models/page.ts +69 -0
- package/src/models/provider-values.ts +42 -0
- package/src/models/providers.ts +34 -0
- package/src/models/registry.ts +74 -0
- package/src/models/validation.ts +24 -0
- package/src/open-sync.ts +182 -0
- package/src/repositories/acquisition/contract.ts +17 -0
- package/src/repositories/acquisition/lease.ts +118 -0
- package/src/repositories/acquisition/outbox.ts +52 -0
- package/src/repositories/acquisition/records.ts +37 -0
- package/src/repositories/acquisition/sqlite.ts +60 -0
- package/src/repositories/catalog/contract.ts +23 -0
- package/src/repositories/catalog/sqlite.ts +166 -0
- package/src/repositories/delivery/contract.ts +23 -0
- package/src/repositories/delivery/sqlite.ts +111 -0
- package/src/repositories/providers/contract.ts +15 -0
- package/src/repositories/providers/sqlite.ts +79 -0
- package/src/repositories/queue-usage.ts +21 -0
- package/src/repositories/rows.ts +50 -0
- package/src/runtime.ts +90 -0
- package/src/services/acquisition.ts +100 -0
- package/src/services/delivery.ts +53 -0
- package/src/services/management.ts +124 -0
- package/src/services/providers/service.ts +190 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { SyncDefinition, SyncPage } from './definition';
|
|
2
|
+
import type { SyncRecord } from './delivery';
|
|
3
|
+
import { fail } from './error';
|
|
4
|
+
import { canonicalJson } from './json';
|
|
5
|
+
import type { QueueLimits } from './limits';
|
|
6
|
+
import { identifier, validate } from './validation';
|
|
7
|
+
|
|
8
|
+
export function preparePage(input: {
|
|
9
|
+
page: SyncPage;
|
|
10
|
+
definition: SyncDefinition;
|
|
11
|
+
limits: QueueLimits;
|
|
12
|
+
}): SyncPage {
|
|
13
|
+
try {
|
|
14
|
+
const json = canonicalJson(input.page);
|
|
15
|
+
if (Buffer.byteLength(json.json) > input.limits.maxPageBytes) {
|
|
16
|
+
fail('invalid_page');
|
|
17
|
+
}
|
|
18
|
+
const page = json.value as unknown as SyncPage;
|
|
19
|
+
if (
|
|
20
|
+
Object.keys(page).some((key) => !['deliverable', 'checkpoint', 'complete'].includes(key)) ||
|
|
21
|
+
typeof page.complete !== 'boolean'
|
|
22
|
+
) {
|
|
23
|
+
fail('invalid_page');
|
|
24
|
+
}
|
|
25
|
+
if (
|
|
26
|
+
Object.keys(page.deliverable).some((key) => key !== 'records') ||
|
|
27
|
+
!Array.isArray(page.deliverable.records) ||
|
|
28
|
+
page.deliverable.records.length > input.limits.maxPageRecords
|
|
29
|
+
) {
|
|
30
|
+
fail('invalid_page');
|
|
31
|
+
}
|
|
32
|
+
validate({ value: page.checkpoint, schema: input.definition.checkpointSchema });
|
|
33
|
+
const identities = new Set<string>();
|
|
34
|
+
for (const record of page.deliverable.records) {
|
|
35
|
+
validateRecord({ record, definition: input.definition });
|
|
36
|
+
const key = JSON.stringify([record.kind, record.id]);
|
|
37
|
+
if (identities.has(key)) {
|
|
38
|
+
fail('duplicate_record');
|
|
39
|
+
}
|
|
40
|
+
identities.add(key);
|
|
41
|
+
}
|
|
42
|
+
return page;
|
|
43
|
+
} catch {
|
|
44
|
+
return fail('invalid_page');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function validateRecord(input: { record: SyncRecord; definition: SyncDefinition }): void {
|
|
48
|
+
const { record, definition } = input;
|
|
49
|
+
identifier(record.kind);
|
|
50
|
+
identifier(record.id);
|
|
51
|
+
if (!Object.hasOwn(definition.kinds, record.kind)) {
|
|
52
|
+
fail('unknown_kind');
|
|
53
|
+
}
|
|
54
|
+
const fields =
|
|
55
|
+
record.operation === 'upsert'
|
|
56
|
+
? ['operation', 'kind', 'id', 'data']
|
|
57
|
+
: ['operation', 'kind', 'id'];
|
|
58
|
+
if (Object.keys(record).some((key) => !fields.includes(key))) {
|
|
59
|
+
fail('invalid_record');
|
|
60
|
+
}
|
|
61
|
+
if (record.operation === 'upsert') {
|
|
62
|
+
if (!record.data || Array.isArray(record.data) || typeof record.data !== 'object') {
|
|
63
|
+
fail('invalid_record');
|
|
64
|
+
}
|
|
65
|
+
validate({ value: record.data, schema: definition.kinds[record.kind]! });
|
|
66
|
+
} else if (record.operation !== 'delete') {
|
|
67
|
+
fail('invalid_record');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { SyncError } from './error';
|
|
2
|
+
import type { ProviderSetup as RuntimeProviderSetup } from './providers';
|
|
3
|
+
|
|
4
|
+
type OAuthSetup = Extract<RuntimeProviderSetup['auth'][number], { type: 'oauth2' }>;
|
|
5
|
+
|
|
6
|
+
export function oauthClientValues(input: { auth: OAuthSetup; values: Record<string, string> }) {
|
|
7
|
+
const extra: Record<string, string> = {};
|
|
8
|
+
const secretExtra: Record<string, string> = {};
|
|
9
|
+
for (const field of input.auth.clientFields) {
|
|
10
|
+
if (field.key === 'clientId' || field.key === 'clientSecret') {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const value = input.values[field.key] ?? field.defaultValue;
|
|
14
|
+
if (value !== undefined) {
|
|
15
|
+
(field.location === 'secretExtra' ? secretExtra : extra)[field.key] = value;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
clientId: input.values.clientId,
|
|
20
|
+
clientSecret: input.values.clientSecret,
|
|
21
|
+
extra,
|
|
22
|
+
secretExtra,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function publicConnection(input: { metadata: Record<string, unknown>; service: string }) {
|
|
27
|
+
const { metadata } = input;
|
|
28
|
+
if (
|
|
29
|
+
typeof metadata.id !== 'string' ||
|
|
30
|
+
metadata.service !== input.service ||
|
|
31
|
+
metadata.status !== 'active'
|
|
32
|
+
) {
|
|
33
|
+
throw new SyncError({
|
|
34
|
+
code: 'connection_unavailable',
|
|
35
|
+
message: 'Provider connection is unavailable.',
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const account = [metadata.accountLabel, metadata.displayName, metadata.providerAccountId].find(
|
|
39
|
+
(value): value is string => typeof value === 'string' && value.length > 0,
|
|
40
|
+
);
|
|
41
|
+
return { connectorId: metadata.id, service: input.service, account: account ?? input.service };
|
|
42
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { RuntimeProviderSetup } from '@oomol-lab/open-connector';
|
|
2
|
+
export type ProviderSetup = RuntimeProviderSetup;
|
|
3
|
+
|
|
4
|
+
import type { Scope } from './identity';
|
|
5
|
+
export type ProviderScope = Scope;
|
|
6
|
+
export interface ProviderConnection {
|
|
7
|
+
id: string;
|
|
8
|
+
connectorId: string;
|
|
9
|
+
service: string;
|
|
10
|
+
account: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ProviderAuthorization {
|
|
13
|
+
id: string;
|
|
14
|
+
requestId: string;
|
|
15
|
+
service: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ProviderCatalogEntry {
|
|
19
|
+
service: string;
|
|
20
|
+
displayName: string;
|
|
21
|
+
iconUrl: string | null;
|
|
22
|
+
categories: { id: string; displayName: string }[];
|
|
23
|
+
scenario: string;
|
|
24
|
+
authTypes: RuntimeProviderSetup['auth'][number]['type'][];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ConnectorManagement {
|
|
28
|
+
catalog(): Promise<ProviderCatalogEntry[]>;
|
|
29
|
+
call(input: {
|
|
30
|
+
path: string;
|
|
31
|
+
method?: 'POST' | 'PUT';
|
|
32
|
+
body?: Record<string, unknown>;
|
|
33
|
+
}): Promise<Record<string, unknown>>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type DefinitionRef,
|
|
3
|
+
definitionKey,
|
|
4
|
+
type SyncDefinition,
|
|
5
|
+
type SyncRegistration,
|
|
6
|
+
} from './definition';
|
|
7
|
+
import type { DestinationType } from './delivery';
|
|
8
|
+
import { fail } from './error';
|
|
9
|
+
import { canonicalJson } from './json';
|
|
10
|
+
import { identifier, validate } from './validation';
|
|
11
|
+
|
|
12
|
+
export class Registry {
|
|
13
|
+
readonly #definitions = new Map<string, SyncRegistration>();
|
|
14
|
+
readonly #destinations: ReadonlyMap<string, DestinationType>;
|
|
15
|
+
constructor(input: {
|
|
16
|
+
definitions: readonly SyncRegistration[];
|
|
17
|
+
destinations: Readonly<Record<string, DestinationType>>;
|
|
18
|
+
}) {
|
|
19
|
+
this.#destinations = new Map(
|
|
20
|
+
Object.entries(input.destinations).map(([name, type]) => {
|
|
21
|
+
identifier(name);
|
|
22
|
+
identifier(type.version);
|
|
23
|
+
return [name, { ...type, configSchema: structuredClone(type.configSchema) }];
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
for (const registration of input.definitions) {
|
|
27
|
+
const definition = canonicalJson(registration.definition).value as unknown as SyncDefinition;
|
|
28
|
+
identifier(definition.id);
|
|
29
|
+
identifier(definition.version);
|
|
30
|
+
identifier(definition.artifactId);
|
|
31
|
+
validate({ value: definition.initialCheckpoint, schema: definition.checkpointSchema });
|
|
32
|
+
if (!Object.keys(definition.kinds).length) {
|
|
33
|
+
fail('missing_record_kinds');
|
|
34
|
+
}
|
|
35
|
+
for (const kind of Object.keys(definition.kinds)) {
|
|
36
|
+
identifier(kind);
|
|
37
|
+
}
|
|
38
|
+
const key = definitionKey(definition);
|
|
39
|
+
if (this.#definitions.has(key)) {
|
|
40
|
+
fail('definition_conflict');
|
|
41
|
+
}
|
|
42
|
+
this.#definitions.set(key, { definition, load: () => registration.load() });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
definitions(): SyncDefinition[] {
|
|
46
|
+
return [...this.#definitions.values()].map((entry) => structuredClone(entry.definition));
|
|
47
|
+
}
|
|
48
|
+
definition(ref: DefinitionRef): SyncRegistration {
|
|
49
|
+
const registration = this.#definitions.get(definitionKey(ref));
|
|
50
|
+
if (!registration) {
|
|
51
|
+
fail('definition_unavailable');
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
definition: structuredClone(registration.definition),
|
|
55
|
+
load: () => registration.load(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
destinationTypes() {
|
|
59
|
+
return [...this.#destinations].map(([type, entry]) => ({
|
|
60
|
+
type,
|
|
61
|
+
version: entry.version,
|
|
62
|
+
name: entry.name,
|
|
63
|
+
description: entry.description,
|
|
64
|
+
configSchema: structuredClone(entry.configSchema),
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
destination(name: string): DestinationType {
|
|
68
|
+
const type = this.#destinations.get(name);
|
|
69
|
+
if (!type) {
|
|
70
|
+
fail('destination_unavailable');
|
|
71
|
+
}
|
|
72
|
+
return type;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Schema, Validator } from '@cfworker/json-schema';
|
|
2
|
+
import { fail } from './error';
|
|
3
|
+
import { canonicalJson, type JsonValue } from './json';
|
|
4
|
+
|
|
5
|
+
const maxIdentifierLength = 1024;
|
|
6
|
+
export function identifier(value: unknown): asserts value is string {
|
|
7
|
+
if (typeof value !== 'string' || !value.trim() || value.length > maxIdentifierLength) {
|
|
8
|
+
fail('invalid_identifier');
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function validate(input: { value: unknown; schema: Schema }): JsonValue {
|
|
12
|
+
try {
|
|
13
|
+
const json = canonicalJson(input.value);
|
|
14
|
+
// The validator mutates schemas while compiling; persisted manifests remain immutable.
|
|
15
|
+
if (
|
|
16
|
+
!new Validator(structuredClone(input.schema), '2020-12', false).validate(json.value).valid
|
|
17
|
+
) {
|
|
18
|
+
fail('invalid_input');
|
|
19
|
+
}
|
|
20
|
+
return json.value;
|
|
21
|
+
} catch {
|
|
22
|
+
return fail('invalid_input');
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/open-sync.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { type ProviderApi, providerApi, type SyncApi, syncApi } from './api';
|
|
3
|
+
import { createConnectorClient } from './connector/client';
|
|
4
|
+
import { loadProviderKey } from './connector/encryption-key';
|
|
5
|
+
import { connectorManagement } from './connector/management';
|
|
6
|
+
import { openProviderDatabase } from './db/providers';
|
|
7
|
+
import type { Logger } from './execution/diagnostics';
|
|
8
|
+
import { createHttpApp } from './http/app';
|
|
9
|
+
import type { SyncRegistration } from './models/definition';
|
|
10
|
+
import type { DestinationType } from './models/delivery';
|
|
11
|
+
import { fail } from './models/error';
|
|
12
|
+
import type { Scope } from './models/identity';
|
|
13
|
+
import { defaultTiming, type QueueLimits } from './models/limits';
|
|
14
|
+
import { SqliteProviders } from './repositories/providers/sqlite';
|
|
15
|
+
import { createSyncRuntime } from './runtime';
|
|
16
|
+
import { ProviderService } from './services/providers/service';
|
|
17
|
+
|
|
18
|
+
export type { Scope } from './models/identity';
|
|
19
|
+
export type { ProviderCatalogEntry, ProviderSetup } from './models/providers';
|
|
20
|
+
|
|
21
|
+
export interface OpenSyncOptions {
|
|
22
|
+
definitions: readonly SyncRegistration[];
|
|
23
|
+
destinationTypes: Readonly<Record<string, DestinationType>>;
|
|
24
|
+
limits?: Partial<QueueLimits>;
|
|
25
|
+
executionTimeoutMs?: number;
|
|
26
|
+
onEvent?: Logger;
|
|
27
|
+
dataDirectory: string;
|
|
28
|
+
/** Absolute URL where the host mounts fetch(), including its path prefix. */
|
|
29
|
+
publicUrl: string;
|
|
30
|
+
/** Host authentication, ownership and CSRF policy for management HTTP requests. */
|
|
31
|
+
authorize(request: Request): Scope | null | Promise<Scope | null>;
|
|
32
|
+
/** OAuth application settings are instance-wide, so require the host's administrator policy. */
|
|
33
|
+
canConfigureProviders(scope: Scope): Promise<boolean>;
|
|
34
|
+
/** Final host UI location after Open Sync completes authorization. */
|
|
35
|
+
authorizationRedirect?(input: { service: string; outcome: 'connected' | 'failed' }): string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Headless application boundary. The host owns its listener; Open Sync owns provider and worker lifecycles. */
|
|
39
|
+
export async function createOpenSync(options: OpenSyncOptions): Promise<OpenSyncRuntime> {
|
|
40
|
+
const base = new URL(options.publicUrl);
|
|
41
|
+
if (
|
|
42
|
+
!['http:', 'https:'].includes(base.protocol) ||
|
|
43
|
+
base.search ||
|
|
44
|
+
base.hash ||
|
|
45
|
+
base.username ||
|
|
46
|
+
base.password
|
|
47
|
+
) {
|
|
48
|
+
fail('invalid_public_url');
|
|
49
|
+
}
|
|
50
|
+
const prefix = base.pathname.replace(/\/$/, '');
|
|
51
|
+
const publicUrl = `${base.origin}${prefix}`;
|
|
52
|
+
const encryptionKey = await loadProviderKey(options.dataDirectory);
|
|
53
|
+
const adminToken = crypto.randomUUID();
|
|
54
|
+
const runtimeToken = crypto.randomUUID();
|
|
55
|
+
// Loading the engine alone does not initialize the provider platform.
|
|
56
|
+
const { createConnectorRuntime } = await import('@oomol-lab/open-connector');
|
|
57
|
+
const connector = await createConnectorRuntime({
|
|
58
|
+
dataDir: join(options.dataDirectory, 'connector'),
|
|
59
|
+
publicOrigin: publicUrl,
|
|
60
|
+
encryptionKey,
|
|
61
|
+
adminToken,
|
|
62
|
+
runtimeToken,
|
|
63
|
+
});
|
|
64
|
+
const lifetime = new AbortController();
|
|
65
|
+
let references: ReturnType<typeof openProviderDatabase> | undefined;
|
|
66
|
+
let engine: ReturnType<typeof createSyncRuntime> | undefined;
|
|
67
|
+
try {
|
|
68
|
+
references = openProviderDatabase(join(options.dataDirectory, 'providers.db'));
|
|
69
|
+
const repository = new SqliteProviders(references);
|
|
70
|
+
const transport = (request: Request) => connector.fetch(request);
|
|
71
|
+
const management = connectorManagement({
|
|
72
|
+
fetch: transport,
|
|
73
|
+
baseUrl: publicUrl,
|
|
74
|
+
adminToken,
|
|
75
|
+
runtimeToken,
|
|
76
|
+
signal: lifetime.signal,
|
|
77
|
+
});
|
|
78
|
+
const client = createConnectorClient({
|
|
79
|
+
fetch: transport,
|
|
80
|
+
baseUrl: publicUrl,
|
|
81
|
+
adminToken,
|
|
82
|
+
runtimeToken,
|
|
83
|
+
authorizeConnection: (input) =>
|
|
84
|
+
repository.owns({ ...input, connectorId: input.connection.id }),
|
|
85
|
+
});
|
|
86
|
+
engine = createSyncRuntime({
|
|
87
|
+
definitions: options.definitions,
|
|
88
|
+
destinationTypes: options.destinationTypes,
|
|
89
|
+
limits: options.limits,
|
|
90
|
+
onEvent: options.onEvent,
|
|
91
|
+
timing: {
|
|
92
|
+
timeoutMs: options.executionTimeoutMs ?? defaultTiming.timeoutMs,
|
|
93
|
+
leaseMs: (options.executionTimeoutMs ?? defaultTiming.timeoutMs) + defaultTiming.timeoutMs,
|
|
94
|
+
},
|
|
95
|
+
databasePath: join(options.dataDirectory, 'sync.db'),
|
|
96
|
+
connector: {
|
|
97
|
+
async bind(input) {
|
|
98
|
+
const owned = await repository.connection({ ...input, id: input.connection.id });
|
|
99
|
+
if (!owned || owned.service !== input.connection.service) {
|
|
100
|
+
fail('not_found');
|
|
101
|
+
}
|
|
102
|
+
return await client.bind({
|
|
103
|
+
...input,
|
|
104
|
+
connection: { id: owned.connectorId, service: input.connection.service },
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
const providers = new ProviderService({
|
|
110
|
+
repository,
|
|
111
|
+
connector: management,
|
|
112
|
+
signal: lifetime.signal,
|
|
113
|
+
canConfigure: options.canConfigureProviders,
|
|
114
|
+
returnUrl: (input) =>
|
|
115
|
+
`${publicUrl}/providers/${encodeURIComponent(input.service)}/return/${input.id}`,
|
|
116
|
+
});
|
|
117
|
+
const api = syncApi(engine.api);
|
|
118
|
+
const http = createHttpApp({
|
|
119
|
+
prefix,
|
|
120
|
+
api,
|
|
121
|
+
providers,
|
|
122
|
+
authorize: options.authorize,
|
|
123
|
+
authorizationRedirect: options.authorizationRedirect,
|
|
124
|
+
});
|
|
125
|
+
let closing: Promise<void> | undefined;
|
|
126
|
+
const runtime = engine;
|
|
127
|
+
const database = references;
|
|
128
|
+
return {
|
|
129
|
+
api,
|
|
130
|
+
providers: providerApi(providers),
|
|
131
|
+
start: () => runtime.start(),
|
|
132
|
+
async fetch(request: Request): Promise<Response> {
|
|
133
|
+
if (lifetime.signal.aborted) {
|
|
134
|
+
return new Response(null, { status: 503 });
|
|
135
|
+
}
|
|
136
|
+
const url = new URL(request.url);
|
|
137
|
+
if (url.pathname === `${prefix}/oauth/callback` && request.method === 'GET') {
|
|
138
|
+
return await connector.fetch(request);
|
|
139
|
+
}
|
|
140
|
+
if (!url.pathname.startsWith(`${prefix}/`)) {
|
|
141
|
+
return new Response(null, { status: 404 });
|
|
142
|
+
}
|
|
143
|
+
return await http.handle(request);
|
|
144
|
+
},
|
|
145
|
+
close() {
|
|
146
|
+
closing ??= (async () => {
|
|
147
|
+
lifetime.abort();
|
|
148
|
+
try {
|
|
149
|
+
await runtime.close();
|
|
150
|
+
} finally {
|
|
151
|
+
try {
|
|
152
|
+
await connector.close();
|
|
153
|
+
} finally {
|
|
154
|
+
database.close();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
})();
|
|
158
|
+
return closing;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
} catch (error) {
|
|
162
|
+
lifetime.abort();
|
|
163
|
+
try {
|
|
164
|
+
await engine?.close();
|
|
165
|
+
} finally {
|
|
166
|
+
try {
|
|
167
|
+
await connector.close();
|
|
168
|
+
} finally {
|
|
169
|
+
references?.close();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export interface OpenSyncRuntime {
|
|
176
|
+
api: SyncApi;
|
|
177
|
+
providers: ProviderApi;
|
|
178
|
+
fetch(request: Request): Promise<Response>;
|
|
179
|
+
start(): void;
|
|
180
|
+
close(): Promise<void>;
|
|
181
|
+
}
|
|
182
|
+
export type { ProviderApi, SyncApi } from './api';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SyncDefinition, SyncPage } from '../../models/definition';
|
|
2
|
+
import type { Scope } from '../../models/identity';
|
|
3
|
+
import type { Installation } from '../../models/installation';
|
|
4
|
+
|
|
5
|
+
export interface RunLease extends Scope {
|
|
6
|
+
id: string;
|
|
7
|
+
installation: Installation;
|
|
8
|
+
workerId: string;
|
|
9
|
+
generation: number;
|
|
10
|
+
checkpointRevision: number;
|
|
11
|
+
}
|
|
12
|
+
export interface AcquisitionRepository {
|
|
13
|
+
claim(leaseMs: number): RunLease | undefined;
|
|
14
|
+
hasCapacity(): boolean;
|
|
15
|
+
commit(input: { lease: RunLease; page: SyncPage; definition: SyncDefinition }): void;
|
|
16
|
+
finish(input: { lease: RunLease; state: string; delay: number }): void;
|
|
17
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
import { definitionKey } from '../../models/definition';
|
|
3
|
+
import { fail } from '../../models/error';
|
|
4
|
+
import { workerScope } from '../../models/identity';
|
|
5
|
+
import type { Installation } from '../../models/installation';
|
|
6
|
+
import { readInstallation } from '../rows';
|
|
7
|
+
import type { RunLease } from './contract';
|
|
8
|
+
|
|
9
|
+
export function assertRun(input: { db: Database; lease: RunLease }): Installation {
|
|
10
|
+
const { db, lease } = input;
|
|
11
|
+
const valid = db
|
|
12
|
+
.query(
|
|
13
|
+
"SELECT 1 FROM runs WHERE owner_id=? AND id=? AND installation_id=? AND binding_epoch=? AND definition_ref=? AND state='running' AND worker_id=? AND generation=? AND expires_at>?",
|
|
14
|
+
)
|
|
15
|
+
.get(
|
|
16
|
+
lease.ownerId,
|
|
17
|
+
lease.id,
|
|
18
|
+
lease.installation.id,
|
|
19
|
+
lease.installation.bindingEpoch,
|
|
20
|
+
definitionKey(lease.installation.definition),
|
|
21
|
+
lease.workerId,
|
|
22
|
+
lease.generation,
|
|
23
|
+
Date.now(),
|
|
24
|
+
);
|
|
25
|
+
const installation = readInstallation({ db, scope: { ...lease, id: lease.installation.id } });
|
|
26
|
+
if (
|
|
27
|
+
!valid ||
|
|
28
|
+
!installation.enabled ||
|
|
29
|
+
installation.bindingEpoch !== lease.installation.bindingEpoch ||
|
|
30
|
+
definitionKey(installation.definition) !== definitionKey(lease.installation.definition)
|
|
31
|
+
) {
|
|
32
|
+
fail('lease_lost');
|
|
33
|
+
}
|
|
34
|
+
if (installation.checkpointRevision !== lease.checkpointRevision) {
|
|
35
|
+
fail('checkpoint_conflict');
|
|
36
|
+
}
|
|
37
|
+
return installation;
|
|
38
|
+
}
|
|
39
|
+
export function claimRun(input: {
|
|
40
|
+
db: Database;
|
|
41
|
+
leaseMs: number;
|
|
42
|
+
historyLimit: number;
|
|
43
|
+
}): RunLease | undefined {
|
|
44
|
+
const { db } = input;
|
|
45
|
+
return db
|
|
46
|
+
.transaction(() => {
|
|
47
|
+
recoverRuns(input);
|
|
48
|
+
if (db.query("SELECT 1 FROM runs WHERE state='running'").get()) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const row = db
|
|
52
|
+
.query<{ id: string; owner_id: string }, [number]>(
|
|
53
|
+
'SELECT id,owner_id FROM installations WHERE enabled=1 AND next_due_at<=? ORDER BY next_due_at,id LIMIT 1',
|
|
54
|
+
)
|
|
55
|
+
.get(Date.now());
|
|
56
|
+
if (!row) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const scope = workerScope(row.owner_id);
|
|
60
|
+
const installation = readInstallation({ db, scope: { ...scope, id: row.id } });
|
|
61
|
+
const lease: RunLease = {
|
|
62
|
+
...scope,
|
|
63
|
+
id: `run_${crypto.randomUUID()}`,
|
|
64
|
+
installation,
|
|
65
|
+
workerId: crypto.randomUUID(),
|
|
66
|
+
generation: 1,
|
|
67
|
+
checkpointRevision: installation.checkpointRevision,
|
|
68
|
+
};
|
|
69
|
+
db.query(`INSERT INTO runs(owner_id,id,installation_id,definition_ref,binding_epoch,worker_id,generation,expires_at,checkpoint_revision,state,started_at)
|
|
70
|
+
VALUES (?,?,?,?,?,?,?,?,?,'running',?)`).run(
|
|
71
|
+
scope.ownerId,
|
|
72
|
+
lease.id,
|
|
73
|
+
installation.id,
|
|
74
|
+
definitionKey(installation.definition),
|
|
75
|
+
installation.bindingEpoch,
|
|
76
|
+
lease.workerId,
|
|
77
|
+
lease.generation,
|
|
78
|
+
Date.now() + input.leaseMs,
|
|
79
|
+
lease.checkpointRevision,
|
|
80
|
+
Date.now(),
|
|
81
|
+
);
|
|
82
|
+
db.query("UPDATE installations SET status='running' WHERE owner_id=? AND id=?").run(
|
|
83
|
+
scope.ownerId,
|
|
84
|
+
installation.id,
|
|
85
|
+
);
|
|
86
|
+
return lease;
|
|
87
|
+
})
|
|
88
|
+
.immediate();
|
|
89
|
+
}
|
|
90
|
+
function recoverRuns(input: { db: Database; historyLimit: number }): void {
|
|
91
|
+
input.db
|
|
92
|
+
.query(`UPDATE installations SET next_due_at=?,status='lease_expired' WHERE enabled=1 AND EXISTS (
|
|
93
|
+
SELECT 1 FROM runs WHERE owner_id=installations.owner_id AND installation_id=installations.id AND state='running' AND expires_at<=?)`)
|
|
94
|
+
.run(Date.now(), Date.now());
|
|
95
|
+
input.db
|
|
96
|
+
.query(
|
|
97
|
+
"UPDATE runs SET state='lease_expired',completed_at=? WHERE state='running' AND expires_at<=?",
|
|
98
|
+
)
|
|
99
|
+
.run(Date.now(), Date.now());
|
|
100
|
+
input.db
|
|
101
|
+
.query(
|
|
102
|
+
"DELETE FROM runs WHERE state!='running' AND rowid NOT IN (SELECT rowid FROM runs ORDER BY started_at DESC,rowid DESC LIMIT ?)",
|
|
103
|
+
)
|
|
104
|
+
.run(input.historyLimit);
|
|
105
|
+
}
|
|
106
|
+
export function finishRun(input: {
|
|
107
|
+
db: Database;
|
|
108
|
+
lease: RunLease;
|
|
109
|
+
state: string;
|
|
110
|
+
delay: number;
|
|
111
|
+
}): void {
|
|
112
|
+
input.db
|
|
113
|
+
.query('UPDATE runs SET state=?,completed_at=? WHERE owner_id=? AND id=?')
|
|
114
|
+
.run(input.state, Date.now(), input.lease.ownerId, input.lease.id);
|
|
115
|
+
input.db
|
|
116
|
+
.query('UPDATE installations SET status=?,next_due_at=? WHERE owner_id=? AND id=?')
|
|
117
|
+
.run(input.state, Date.now() + input.delay, input.lease.ownerId, input.lease.installation.id);
|
|
118
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
import type { DeliveredRecord, Delivery } from '../../models/delivery';
|
|
3
|
+
import { fail } from '../../models/error';
|
|
4
|
+
import type { Installation } from '../../models/installation';
|
|
5
|
+
import { canonicalJson } from '../../models/json';
|
|
6
|
+
import type { QueueLimits } from '../../models/limits';
|
|
7
|
+
import { queueUsage } from '../queue-usage';
|
|
8
|
+
|
|
9
|
+
export function enqueue(input: {
|
|
10
|
+
db: Database;
|
|
11
|
+
installation: Installation;
|
|
12
|
+
records: DeliveredRecord[];
|
|
13
|
+
limits: QueueLimits;
|
|
14
|
+
}): void {
|
|
15
|
+
if (!input.records.length) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const { installation, db, limits } = input;
|
|
19
|
+
const delivery: Delivery = {
|
|
20
|
+
version: 1,
|
|
21
|
+
id: `delivery_${crypto.randomUUID()}`,
|
|
22
|
+
ownerId: installation.ownerId,
|
|
23
|
+
sourceId: installation.sourceId,
|
|
24
|
+
installationId: installation.id,
|
|
25
|
+
definition: installation.definition,
|
|
26
|
+
deliverable: { records: input.records },
|
|
27
|
+
};
|
|
28
|
+
const body = canonicalJson(delivery).json;
|
|
29
|
+
const bytes = Buffer.byteLength(body);
|
|
30
|
+
if (bytes > limits.maxPendingBytes || input.records.length > limits.maxPendingRecords) {
|
|
31
|
+
fail('page_exceeds_queue_capacity');
|
|
32
|
+
}
|
|
33
|
+
const usage = queueUsage({ db });
|
|
34
|
+
if (
|
|
35
|
+
bytes + usage.pendingBytes > limits.maxPendingBytes ||
|
|
36
|
+
input.records.length + usage.pendingRecords > limits.maxPendingRecords
|
|
37
|
+
) {
|
|
38
|
+
fail('waiting_for_capacity');
|
|
39
|
+
}
|
|
40
|
+
db.query(
|
|
41
|
+
'INSERT INTO deliveries(owner_id,id,installation_id,destination_id,body,bytes,record_count,due_at) VALUES (?,?,?,?,?,?,?,?)',
|
|
42
|
+
).run(
|
|
43
|
+
installation.ownerId,
|
|
44
|
+
delivery.id,
|
|
45
|
+
installation.id,
|
|
46
|
+
installation.destinationId,
|
|
47
|
+
body,
|
|
48
|
+
bytes,
|
|
49
|
+
input.records.length,
|
|
50
|
+
Date.now(),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
import type { DeliveredRecord, SyncRecord } from '../../models/delivery';
|
|
3
|
+
import type { Installation } from '../../models/installation';
|
|
4
|
+
import { canonicalJson } from '../../models/json';
|
|
5
|
+
|
|
6
|
+
export function writeRecord(input: {
|
|
7
|
+
db: Database;
|
|
8
|
+
installation: Installation;
|
|
9
|
+
record: SyncRecord;
|
|
10
|
+
}): DeliveredRecord | undefined {
|
|
11
|
+
const { db, installation, record } = input;
|
|
12
|
+
const previous = db
|
|
13
|
+
.query<{ hash: string; revision: number; deleted: number }, [string, string, string, string]>(
|
|
14
|
+
'SELECT hash,revision,deleted FROM records WHERE owner_id=? AND installation_id=? AND kind=? AND id=?',
|
|
15
|
+
)
|
|
16
|
+
.get(installation.ownerId, installation.id, record.kind, record.id);
|
|
17
|
+
if (record.operation === 'delete' && (!previous || previous.deleted === 1)) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const contentHash =
|
|
21
|
+
record.operation === 'upsert' ? canonicalJson(record.data).sha256 : previous!.hash;
|
|
22
|
+
if (record.operation === 'upsert' && previous?.deleted === 0 && previous.hash === contentHash) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const revision = (previous?.revision ?? 0) + 1;
|
|
26
|
+
db.query(`INSERT INTO records VALUES (?,?,?,?,?,?,?) ON CONFLICT(owner_id,installation_id,kind,id)
|
|
27
|
+
DO UPDATE SET hash=excluded.hash,revision=excluded.revision,deleted=excluded.deleted`).run(
|
|
28
|
+
installation.ownerId,
|
|
29
|
+
installation.id,
|
|
30
|
+
record.kind,
|
|
31
|
+
record.id,
|
|
32
|
+
contentHash,
|
|
33
|
+
revision,
|
|
34
|
+
Number(record.operation === 'delete'),
|
|
35
|
+
);
|
|
36
|
+
return { ...record, eventId: `event_${crypto.randomUUID()}`, revision, contentHash };
|
|
37
|
+
}
|