@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,100 @@
1
+ import type { Logger } from '../execution/diagnostics';
2
+ import { bindProvider, type ProviderGateway } from '../execution/provider';
3
+ import { SyncError } from '../models/error';
4
+ import type { Timing } from '../models/limits';
5
+ import type { Registry } from '../models/registry';
6
+ import type { AcquisitionRepository, RunLease } from '../repositories/acquisition/contract';
7
+
8
+ export class AcquisitionService {
9
+ constructor(
10
+ private readonly input: {
11
+ repository: AcquisitionRepository;
12
+ registry: Registry;
13
+ gateway?: ProviderGateway;
14
+ timing: Timing;
15
+ log: Logger;
16
+ },
17
+ ) {}
18
+ claim() {
19
+ return this.input.repository.claim(this.input.timing.leaseMs);
20
+ }
21
+ async execute(input: { lease: RunLease; signal: AbortSignal }): Promise<void> {
22
+ const { repository, timing, log } = this.input;
23
+ const { lease, signal } = input;
24
+ try {
25
+ if (!repository.hasCapacity()) {
26
+ this.finish({ lease, state: 'waiting_for_capacity', delay: timing.retryMs });
27
+ return;
28
+ }
29
+ await this.consume(input);
30
+ } catch (error) {
31
+ const code = signal.aborted
32
+ ? 'cancelled'
33
+ : error instanceof SyncError
34
+ ? error.code
35
+ : 'execution_failed';
36
+ log({ code, ownerId: lease.ownerId, installationId: lease.installation.id });
37
+ this.finish({ lease, state: code, delay: timing.retryMs });
38
+ }
39
+ }
40
+ private async consume(input: { lease: RunLease; signal: AbortSignal }): Promise<void> {
41
+ const { repository, registry, timing } = this.input;
42
+ const { lease, signal } = input;
43
+ const entry = registry.definition(lease.installation.definition);
44
+ const provider = await bindProvider({
45
+ actorId: lease.actorId,
46
+ ownerId: lease.ownerId,
47
+ gateway: this.input.gateway,
48
+ connection: lease.installation.connection,
49
+ requirements: entry.definition.provider,
50
+ signal,
51
+ });
52
+ const executable = await entry.load();
53
+ signal.throwIfAborted();
54
+ let pages = 0;
55
+ for await (const page of executable.run({
56
+ config: lease.installation.config,
57
+ checkpoint: lease.installation.checkpoint,
58
+ sourceId: lease.installation.sourceId,
59
+ signal,
60
+ provider,
61
+ log: (event) =>
62
+ this.input.log({
63
+ ...event,
64
+ code: 'definition_log',
65
+ ownerId: lease.ownerId,
66
+ installationId: lease.installation.id,
67
+ }),
68
+ })) {
69
+ signal.throwIfAborted();
70
+ repository.commit({ lease, page, definition: entry.definition });
71
+ pages++;
72
+ if (page.complete) {
73
+ return;
74
+ }
75
+ if (!repository.hasCapacity()) {
76
+ this.finish({ lease, state: 'waiting_for_capacity', delay: timing.retryMs });
77
+ return;
78
+ }
79
+ if (pages >= timing.maxPages) {
80
+ this.finish({ lease, state: 'yielded', delay: 0 });
81
+ return;
82
+ }
83
+ }
84
+ throw new SyncError({
85
+ code: 'incomplete_run',
86
+ message: 'Definition ended without a complete page.',
87
+ });
88
+ }
89
+ private finish(input: { lease: RunLease; state: string; delay: number }): void {
90
+ try {
91
+ this.input.repository.finish(input);
92
+ } catch (error) {
93
+ if (
94
+ !(error instanceof SyncError && ['lease_lost', 'checkpoint_conflict'].includes(error.code))
95
+ ) {
96
+ throw error;
97
+ }
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,53 @@
1
+ import type { DeliveryResult } from '../models/delivery';
2
+ import { validateResult } from '../models/delivery-result';
3
+ import { SyncError } from '../models/error';
4
+ import { retryDelay, type Timing } from '../models/limits';
5
+ import type { Registry } from '../models/registry';
6
+ import type { DeliveryRepository } from '../repositories/delivery/contract';
7
+
8
+ export class DeliveryService {
9
+ constructor(
10
+ private readonly input: { repository: DeliveryRepository; registry: Registry; timing: Timing },
11
+ ) {}
12
+ async execute(signal: AbortSignal): Promise<void> {
13
+ const { repository, registry, timing } = this.input;
14
+ const lease = repository.claim(timing.leaseMs);
15
+ if (!lease) {
16
+ return;
17
+ }
18
+ let result: DeliveryResult;
19
+ try {
20
+ const type = registry.destination(lease.destination.type);
21
+ if (type.version !== lease.destination.version) {
22
+ result = { status: 'rejected', code: 'destination_unavailable' };
23
+ } else {
24
+ result = validateResult(
25
+ await type.deliver({
26
+ scope: { actorId: lease.actorId, ownerId: lease.ownerId },
27
+ config: structuredClone(lease.destination.config),
28
+ delivery: structuredClone(lease.delivery),
29
+ signal,
30
+ }),
31
+ );
32
+ }
33
+ signal.throwIfAborted();
34
+ } catch (error) {
35
+ result =
36
+ error instanceof SyncError && error.code === 'destination_unavailable'
37
+ ? { status: 'rejected', code: 'destination_unavailable' }
38
+ : { status: 'retry', code: 'delivery_failed' };
39
+ }
40
+ const delay = retryDelay({
41
+ attempt: lease.attempt,
42
+ retryMs: timing.retryMs,
43
+ resultDelay: result.status === 'retry' ? result.retryAfterMs : undefined,
44
+ });
45
+ try {
46
+ repository.complete({ lease, result, delay });
47
+ } catch (error) {
48
+ if (!(error instanceof SyncError && error.code === 'lease_lost')) {
49
+ throw error;
50
+ }
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,124 @@
1
+ import { bindProvider, type ProviderGateway } from '../execution/provider';
2
+ import type { WorkerControl } from '../execution/worker';
3
+ import { fail } from '../models/error';
4
+ import type { Resource, Scope } from '../models/identity';
5
+ import type { CreateInstallation } from '../models/installation';
6
+ import type { JsonObject } from '../models/json';
7
+ import { positive, type QueueLimits } from '../models/limits';
8
+ import type { Registry } from '../models/registry';
9
+ import { identifier, validate } from '../models/validation';
10
+ import type { CatalogRepository } from '../repositories/catalog/contract';
11
+ import type { DeliveryRepository } from '../repositories/delivery/contract';
12
+
13
+ export class SyncManagement {
14
+ constructor(
15
+ private readonly input: {
16
+ catalog: CatalogRepository;
17
+ deliveries: DeliveryRepository;
18
+ registry: Registry;
19
+ worker: WorkerControl;
20
+ gateway?: ProviderGateway;
21
+ limits: QueueLimits;
22
+ timeoutMs: number;
23
+ },
24
+ ) {}
25
+ private guard(scope: Scope): void {
26
+ this.input.worker.ensureOpen();
27
+ identifier(scope.actorId);
28
+ identifier(scope.ownerId);
29
+ }
30
+ definitions(scope: Scope) {
31
+ this.guard(scope);
32
+ return this.input.registry.definitions();
33
+ }
34
+ destinationTypes(scope: Scope) {
35
+ this.guard(scope);
36
+ return this.input.registry.destinationTypes();
37
+ }
38
+ runs(input: Resource & { offset?: number }) {
39
+ this.guard(input);
40
+ const offset = input.offset ?? 0;
41
+ positive(offset + 1);
42
+ return this.input.catalog.runs({ ...input, offset });
43
+ }
44
+ destinations(scope: Scope) {
45
+ this.guard(scope);
46
+ return this.input.catalog
47
+ .destinations(scope)
48
+ .map(({ id, type, version }) => ({ id, type, version }));
49
+ }
50
+ createDestination(input: Scope & { type: string; config: JsonObject }) {
51
+ this.guard(input);
52
+ const type = this.input.registry.destination(input.type);
53
+ const config = validate({ value: input.config, schema: type.configSchema }) as JsonObject;
54
+ const { id, version } = this.input.catalog.createDestination({
55
+ ...input,
56
+ config,
57
+ version: type.version,
58
+ });
59
+ return { id, type: input.type, version };
60
+ }
61
+ async createInstallation(input: CreateInstallation) {
62
+ this.guard(input);
63
+ const { definition } = this.input.registry.definition(input.definition);
64
+ const config = validate({ value: input.config, schema: definition.configSchema }) as JsonObject;
65
+ if (input.intervalMs !== undefined) {
66
+ positive(input.intervalMs);
67
+ }
68
+ if (definition.provider) {
69
+ await bindProvider({
70
+ actorId: input.actorId,
71
+ ownerId: input.ownerId,
72
+ connection: input.connection,
73
+ requirements: definition.provider,
74
+ gateway: this.input.gateway,
75
+ signal: AbortSignal.timeout(this.input.timeoutMs),
76
+ });
77
+ } else if (input.connection) {
78
+ fail('unexpected_connection');
79
+ }
80
+ this.guard(input);
81
+ return this.input.catalog.createInstallation({
82
+ ...input,
83
+ config,
84
+ initialCheckpoint: definition.initialCheckpoint,
85
+ });
86
+ }
87
+ installations(scope: Scope) {
88
+ this.guard(scope);
89
+ return this.input.catalog.installations(scope);
90
+ }
91
+ installation(input: Resource) {
92
+ this.guard(input);
93
+ return this.input.catalog.installation(input);
94
+ }
95
+ async setEnabled(input: Resource & { enabled: boolean }) {
96
+ this.guard(input);
97
+ const result = this.input.catalog.setEnabled(input);
98
+ await this.input.worker.cancel(input);
99
+ return result;
100
+ }
101
+ queueRun(input: Resource & { backfill?: boolean }): void {
102
+ this.guard(input);
103
+ const installation = this.input.catalog.installation(input);
104
+ const { definition } = this.input.registry.definition(installation.definition);
105
+ this.input.catalog.queue({
106
+ ...input,
107
+ checkpoint: input.backfill ? definition.initialCheckpoint : undefined,
108
+ });
109
+ }
110
+ status(scope: Scope) {
111
+ this.guard(scope);
112
+ return { queue: this.input.deliveries.status(scope), limits: { ...this.input.limits } };
113
+ }
114
+ deliveries(input: Scope & { offset?: number }) {
115
+ this.guard(input);
116
+ const offset = input.offset ?? 0;
117
+ positive(offset + 1);
118
+ return this.input.deliveries.pending({ ...input, offset });
119
+ }
120
+ retryDelivery(input: Resource): void {
121
+ this.guard(input);
122
+ this.input.deliveries.retry(input);
123
+ }
124
+ }
@@ -0,0 +1,190 @@
1
+ import { fail, SyncError } from '../../models/error';
2
+ import { oauthClientValues, publicConnection } from '../../models/provider-values';
3
+ import type {
4
+ ConnectorManagement,
5
+ ProviderScope,
6
+ ProviderSetup as RuntimeProviderSetup,
7
+ } from '../../models/providers';
8
+ import { identifier } from '../../models/validation';
9
+ import type { ProviderRepository } from '../../repositories/providers/contract';
10
+
11
+ export class ProviderService {
12
+ constructor(
13
+ private readonly input: {
14
+ repository: ProviderRepository;
15
+ connector: ConnectorManagement;
16
+ returnUrl(input: { service: string; id: string }): string;
17
+ canConfigure(scope: ProviderScope): Promise<boolean>;
18
+ signal: AbortSignal;
19
+ },
20
+ ) {}
21
+
22
+ private guard(scope: ProviderScope) {
23
+ this.input.signal.throwIfAborted();
24
+ identifier(scope.ownerId);
25
+ identifier(scope.actorId);
26
+ }
27
+ async connections(scope: ProviderScope) {
28
+ this.guard(scope);
29
+ return (await this.input.repository.list(scope)).map(({ id, service, account }) => ({
30
+ id,
31
+ service,
32
+ account,
33
+ }));
34
+ }
35
+ async connection(input: ProviderScope & { id: string }) {
36
+ this.guard(input);
37
+ const connection = await this.input.repository.connection(input);
38
+ if (!connection) {
39
+ fail('not_found');
40
+ }
41
+ return { id: connection.id, service: connection.service, account: connection.account };
42
+ }
43
+ async catalog(scope: ProviderScope) {
44
+ this.guard(scope);
45
+ const providers = await this.input.connector.catalog();
46
+ return providers.map(({ service, displayName, iconUrl, authTypes, categories, scenario }) => ({
47
+ service,
48
+ displayName,
49
+ iconUrl,
50
+ authTypes,
51
+ categories,
52
+ scenario,
53
+ }));
54
+ }
55
+ private async setup(service: string): Promise<RuntimeProviderSetup> {
56
+ const setup = await this.input.connector.call({
57
+ path: `/v1/providers/${encodeURIComponent(service)}/setup`,
58
+ });
59
+ return setup as unknown as RuntimeProviderSetup;
60
+ }
61
+ async status(input: ProviderScope & { service: string }) {
62
+ this.guard(input);
63
+ const provider = (await this.catalog(input)).find((entry) => entry.service === input.service);
64
+ if (!provider) {
65
+ fail('not_found');
66
+ }
67
+ const setup = await this.setup(input.service);
68
+ const connections = await this.input.repository.list(input);
69
+ return {
70
+ provider,
71
+ setup,
72
+ connections: await Promise.all(
73
+ connections
74
+ .filter((connection) => connection.service === input.service)
75
+ .map(async ({ id, account, connectorId }) => {
76
+ const metadata = await this.input.connector
77
+ .call({ path: `/v1/connections/by-id/${encodeURIComponent(connectorId)}` })
78
+ .catch(() => null);
79
+ const status =
80
+ metadata?.id === connectorId &&
81
+ metadata.service === input.service &&
82
+ (metadata.status === 'active' || metadata.status === 'reauth_required')
83
+ ? metadata.status
84
+ : 'unknown';
85
+ return { id, account, status };
86
+ }),
87
+ ),
88
+ };
89
+ }
90
+ async configure(input: ProviderScope & { service: string; values: Record<string, string> }) {
91
+ this.guard(input);
92
+ if (!(await this.input.canConfigure(input))) {
93
+ fail('forbidden');
94
+ }
95
+ const setup = await this.setup(input.service);
96
+ const auth = setup.auth.find((method) => method.type === 'oauth2');
97
+ if (!auth) {
98
+ throw new SyncError({
99
+ code: 'provider_request_failed',
100
+ message: 'This provider does not support OAuth.',
101
+ });
102
+ }
103
+ await this.input.connector.call({
104
+ path: `/api/oauth/configs/${encodeURIComponent(input.service)}`,
105
+ method: 'PUT',
106
+ body: oauthClientValues({ auth, values: input.values }),
107
+ });
108
+ return { configured: true };
109
+ }
110
+ async start(input: ProviderScope & { service: string; authorizationOptionIds?: string[] }) {
111
+ this.guard(input);
112
+ const id = `connection_${crypto.randomUUID()}`;
113
+ const result = await this.input.connector.call({
114
+ path: `/v1/connections/${encodeURIComponent(input.service)}/connect`,
115
+ method: 'POST',
116
+ body: {
117
+ returnUri: this.input.returnUrl({ service: input.service, id }),
118
+ authorizationOptionIds: input.authorizationOptionIds,
119
+ },
120
+ });
121
+ if (
122
+ typeof result.authorizationUrl !== 'string' ||
123
+ typeof result.connectionRequestId !== 'string'
124
+ ) {
125
+ throw new SyncError({
126
+ code: 'provider_request_failed',
127
+ message: 'Incomplete authorization response.',
128
+ });
129
+ }
130
+ this.input.signal.throwIfAborted();
131
+ await this.input.repository.start({ ...input, id, requestId: result.connectionRequestId });
132
+ return { authorizationUrl: result.authorizationUrl };
133
+ }
134
+ async credentials(
135
+ input: ProviderScope & {
136
+ service: string;
137
+ authType: 'api_key' | 'custom_credential';
138
+ values: Record<string, string>;
139
+ },
140
+ ) {
141
+ this.guard(input);
142
+ const { apiKey, ...extra } = input.values;
143
+ const result = await this.input.connector.call({
144
+ path: `/v1/connections/${encodeURIComponent(input.service)}/connect/${input.authType === 'api_key' ? 'api-key' : 'custom-credential'}`,
145
+ method: 'POST',
146
+ body: input.authType === 'api_key' ? { apiKey, extra } : { values: input.values },
147
+ });
148
+ const connection = publicConnection({ metadata: result, service: input.service });
149
+ this.input.signal.throwIfAborted();
150
+ await this.input.repository.add({
151
+ actorId: input.actorId,
152
+ ownerId: input.ownerId,
153
+ id: `connection_${crypto.randomUUID()}`,
154
+ ...connection,
155
+ });
156
+ return { connected: true };
157
+ }
158
+ async complete(input: ProviderScope & { service: string; id: string }): Promise<void> {
159
+ this.guard(input);
160
+ const existing = await this.input.repository.connection(input);
161
+ if (existing?.service === input.service) {
162
+ return;
163
+ }
164
+ const pending = await this.input.repository.pending(input);
165
+ if (!pending || pending.service !== input.service) {
166
+ fail('not_found');
167
+ }
168
+ const result = await this.input.connector.call({
169
+ path: `/v1/connection-requests/${encodeURIComponent(pending.requestId)}`,
170
+ });
171
+ if (result.status !== 'connected' || typeof result.appId !== 'string') {
172
+ throw new SyncError({
173
+ code: 'provider_request_failed',
174
+ message: 'Authorization did not complete. Connect again to retry.',
175
+ });
176
+ }
177
+ const metadata = await this.input.connector.call({
178
+ path: `/v1/connections/by-id/${encodeURIComponent(result.appId)}`,
179
+ });
180
+ const connection = publicConnection({ metadata, service: input.service });
181
+ if (connection.connectorId !== result.appId) {
182
+ throw new SyncError({ code: 'provider_request_failed', message: 'Invalid connection.' });
183
+ }
184
+ this.input.signal.throwIfAborted();
185
+ await this.input.repository.complete({ ...input, ...connection });
186
+ if (!(await this.input.repository.connection(input))) {
187
+ fail('not_found');
188
+ }
189
+ }
190
+ }