@kubun/store-connector 0.17.1 → 0.18.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/lib/api.js CHANGED
@@ -1,179 +1,124 @@
1
- // --- Serialization helpers ---
2
- // `checkpoint` and `recovery` are JSON columns: ParseJSONResultsPlugin may have
3
- // already parsed them (Postgres) or left them as strings (SQLite), so handle
4
- // both. Mirrors the `scopes` handling in `consumePendingAuth`.
5
- function decodeJSONColumn(value) {
6
- if (value == null) return null;
7
- return typeof value === 'string' ? JSON.parse(value) : value;
8
- }
9
- function encodeJSONColumn(value) {
10
- return value != null ? JSON.stringify(value) : null;
11
- }
12
- function rowToSyncState(row) {
13
- const state = {
14
- checkpoint: decodeJSONColumn(row.checkpoint),
15
- lastSyncedAt: row.last_synced_at,
16
- entityCount: row.entity_count,
17
- status: row.status,
18
- leaseExpiresAt: row.lease_expires_at,
19
- recovery: decodeJSONColumn(row.recovery),
20
- version: row.version
21
- };
22
- if (row.incarnation != null) {
23
- state.incarnation = row.incarnation;
24
- }
25
- if (row.error != null) {
26
- state.error = row.error;
27
- }
28
- return state;
29
- }
1
+ import { AbstractPostgresAdapter } from '@kubun/db-adapter';
2
+ import { createDocumentSweepMethods } from './document-sweep.js';
3
+ import { createEntityTrimMethods } from './entity-trim.js';
4
+ import { createObservationRecordMethods } from './observation-records.js';
5
+ import { createOperationsMethods } from './operations.js';
6
+ import { createPendingAuthMethods } from './pending-auth.js';
7
+ import { createProjectionWatchesMethods } from './projection-watches.js';
8
+ import { createRetentionMethods } from './retention.js';
9
+ import { encodeJSONColumn } from './serialization.js';
10
+ import { createSweepStateMethods } from './sweep-state.js';
11
+ import { createSyncStateMethods } from './sync-state.js';
30
12
  // --- Factory function ---
31
- export function createConnectorStore(db, _adapter) {
32
- return {
33
- // --- Sync state operations ---
34
- async getSyncState (connectorName, ownerDID) {
35
- const row = await db.selectFrom('kubun_connector_sync_state').selectAll().where('connector_name', '=', connectorName).where('owner_did', '=', ownerDID).executeTakeFirst();
36
- if (row == null) return null;
37
- return rowToSyncState(row);
38
- },
39
- async setSyncState (connectorName, ownerDID, state) {
40
- // `lease_expires_at` is dead: a fresh INSERT sets it null and a conflicting
41
- // update leaves it untouched. A fresh INSERT seeds version 0 and a random
42
- // incarnation; a conflicting update leaves both untouched (this path is not
43
- // the CAS write — it does not bump version or churn the incarnation).
44
- const checkpoint = encodeJSONColumn(state.checkpoint);
45
- const recovery = encodeJSONColumn(state.recovery);
46
- await db.insertInto('kubun_connector_sync_state').values({
47
- connector_name: connectorName,
48
- owner_did: ownerDID,
49
- checkpoint,
50
- last_synced_at: state.lastSyncedAt,
51
- entity_count: state.entityCount,
52
- status: state.status,
53
- error: state.error ?? null,
54
- lease_expires_at: null,
55
- recovery,
56
- version: 0,
57
- incarnation: globalThis.crypto.randomUUID()
58
- }).onConflict((oc)=>oc.columns([
59
- 'connector_name',
60
- 'owner_did'
61
- ]).doUpdateSet({
62
- checkpoint,
63
- last_synced_at: state.lastSyncedAt,
64
- entity_count: state.entityCount,
65
- status: state.status,
66
- error: state.error ?? null,
67
- recovery
68
- })).execute();
69
- },
70
- async updateSyncProgress (connectorName, ownerDID, recompute, options) {
71
- // The caller passes the authoritative MAX_CAS_RETRIES; 5 is a defensive
72
- // fallback for direct callers/tests.
73
- const maxRetries = options?.maxRetries ?? 5;
74
- for(let attempt = 0; attempt <= maxRetries; attempt++){
75
- const row = await db.selectFrom('kubun_connector_sync_state').selectAll().where('connector_name', '=', connectorName).where('owner_did', '=', ownerDID).executeTakeFirst();
76
- if (row == null) {
77
- // No row yet: INSERT ... ON CONFLICT DO NOTHING so a lost insert race
78
- // (a concurrent writer created the row first) falls through to reload
79
- // and take the update path.
80
- const result = await recompute(null);
81
- const inserted = await db.insertInto('kubun_connector_sync_state').values({
82
- connector_name: connectorName,
83
- owner_did: ownerDID,
84
- checkpoint: encodeJSONColumn(result.checkpoint),
85
- last_synced_at: result.lastSyncedAt,
86
- entity_count: result.entityCount,
87
- status: 'idle',
88
- error: result.error ?? null,
89
- lease_expires_at: null,
90
- recovery: encodeJSONColumn(result.recovery),
91
- version: 0,
92
- incarnation: globalThis.crypto.randomUUID()
93
- }).onConflict((oc)=>oc.columns([
94
- 'connector_name',
95
- 'owner_did'
96
- ]).doNothing()).executeTakeFirst();
97
- if ((inserted?.numInsertedOrUpdatedRows ?? 0n) > 0n) return;
98
- continue;
13
+ export function createConnectorStore(db, adapter, deps) {
14
+ // `.forUpdate()` compiles to a `FOR UPDATE` clause via Kysely's default
15
+ // compiler; SQLite has no dedicated override and errors on it (no such
16
+ // clause), so it is applied conditionally, never on SQLite. Postgres row
17
+ // locking is what makes a concurrent `armWatch`/`finalizeOperationAndArmWatch`
18
+ // on the same identity block instead of interleave; SQLite's single
19
+ // connection already serializes the whole transaction with no clause
20
+ // needed.
21
+ const isPostgres = adapter instanceof AbstractPostgresAdapter;
22
+ const { getRandomID } = deps;
23
+ // Declared as a named const (not an inline `return {...}`) so
24
+ // `requestRepull` can delegate to `api.armWatch` below — by the time
25
+ // `requestRepull` is actually invoked, `api` has long since finished
26
+ // initializing (closures capture the binding, not a snapshot), so the
27
+ // forward reference is safe.
28
+ const context = {
29
+ db,
30
+ adapter,
31
+ isPostgres,
32
+ deps,
33
+ getRandomID
34
+ };
35
+ const api = {
36
+ ...createObservationRecordMethods(db, adapter),
37
+ ...createSyncStateMethods(context),
38
+ ...createSweepStateMethods(context),
39
+ ...createEntityTrimMethods(context),
40
+ ...createDocumentSweepMethods(context),
41
+ ...createPendingAuthMethods(context),
42
+ ...createOperationsMethods(context),
43
+ async finalizeOperationAndArmWatch (input) {
44
+ const now = Date.now();
45
+ return await db.transaction().execute(async (tx)=>{
46
+ // Conditional UPDATE first: the fenced lease CAS gate. `version` is
47
+ // deliberately NOT bumped here (see `FinalizeOperationInput`'s doc) —
48
+ // only the watch row's own `version` advances below.
49
+ const updated = await tx.updateTable('kubun_connector_operation').set({
50
+ state: 'committed',
51
+ document_id: input.documentID,
52
+ result_entity: encodeJSONColumn(input.resultEntity),
53
+ updated_at: now
54
+ }).where('caller_did', '=', input.key.callerDID).where('operation_id', '=', input.key.operationID).where('state', 'in', [
55
+ 'attempted',
56
+ 'committed'
57
+ ]).where('version', '=', input.version).where('lease_token', '=', input.leaseToken).executeTakeFirst();
58
+ // Lost CAS: the transaction commits with NO watch write — the point
59
+ // is that the upsert below never runs, so a stale-lease finalize
60
+ // leaves the watch row untouched (absent if it never existed).
61
+ if ((updated?.numUpdatedRows ?? 0n) === 0n) {
62
+ return false;
99
63
  }
100
- const result = await recompute({
101
- checkpoint: decodeJSONColumn(row.checkpoint),
102
- recovery: decodeJSONColumn(row.recovery),
103
- version: row.version,
104
- incarnation: row.incarnation ?? ''
105
- });
106
- // Single conditional UPDATE guarded on the read version AND incarnation.
107
- // `status` is deliberately omitted (workflow owns it). Zero rows means a
108
- // concurrent writer won, or the row was delete+recreated (incarnation
109
- // differs) ⇒ reload and recompute.
110
- const updated = await db.updateTable('kubun_connector_sync_state').set({
111
- checkpoint: encodeJSONColumn(result.checkpoint),
112
- last_synced_at: result.lastSyncedAt,
113
- entity_count: result.entityCount,
114
- error: result.error ?? null,
115
- recovery: encodeJSONColumn(result.recovery),
116
- version: row.version + 1
117
- }).where('connector_name', '=', connectorName).where('owner_did', '=', ownerDID).where('version', '=', row.version).where('incarnation', '=', row.incarnation).executeTakeFirst();
118
- if ((updated?.numUpdatedRows ?? 0n) > 0n) return;
119
- }
120
- throw new Error(`updateSyncProgress: CAS failed after ${maxRetries + 1} attempts for ${connectorName}/${ownerDID}`);
121
- },
122
- async deleteSyncState (connectorName, ownerDID) {
123
- await db.deleteFrom('kubun_connector_sync_state').where('connector_name', '=', connectorName).where('owner_did', '=', ownerDID).execute();
124
- },
125
- async listSyncStatesForConnector (connectorName) {
126
- const rows = await db.selectFrom('kubun_connector_sync_state').selectAll().where('connector_name', '=', connectorName).execute();
127
- return rows.map((row)=>({
128
- ...rowToSyncState(row),
129
- connectorName: row.connector_name,
130
- ownerDID: row.owner_did
131
- }));
132
- },
133
- // --- Pending OAuth authorization operations ---
134
- async createPendingAuth (record) {
135
- await db.insertInto('kubun_connector_pending_auth').values({
136
- state: record.state,
137
- code_verifier: record.codeVerifier,
138
- provider: record.provider,
139
- owner_did: record.ownerDID,
140
- owner_wrappable_did: record.ownerWrappableDID ?? null,
141
- controller_wrappable_did: record.controllerWrappableDID ?? null,
142
- created_at: record.createdAt,
143
- redirect_url: record.redirectURL,
144
- scopes: JSON.stringify(record.scopes),
145
- expires_at: record.expiresAt
146
- }).execute();
64
+ // One watch per document: a watch the document still has under
65
+ // another provider identity (the same provider id written under a
66
+ // second account, say) is superseded by this one, not kept beside it.
67
+ const identity = input.watchIdentity;
68
+ await tx.deleteFrom('kubun_connector_projection_watch').where('owner_did', '=', identity.ownerDID).where('model', '=', identity.model).where('document_id', '=', input.documentID).where((eb)=>eb.or([
69
+ eb('connector_name', '!=', identity.connectorName),
70
+ eb('account_key', '!=', identity.accountKey),
71
+ eb('source_id', '!=', identity.sourceID)
72
+ ])).execute();
73
+ await tx.insertInto('kubun_connector_projection_watch').values({
74
+ owner_did: input.watchIdentity.ownerDID,
75
+ connector_name: input.watchIdentity.connectorName,
76
+ model: input.watchIdentity.model,
77
+ account_key: input.watchIdentity.accountKey,
78
+ source_id: input.watchIdentity.sourceID,
79
+ document_id: input.documentID,
80
+ verified_state_digest: null,
81
+ observed_winner_digest: input.currentWinnerDigest,
82
+ verification_status: 'pending_verification',
83
+ verification_attempts: 0,
84
+ repair_generation: 0,
85
+ next_attempt_at: now,
86
+ version: 0,
87
+ created_at: now,
88
+ updated_at: now
89
+ }).onConflict((oc)=>oc.columns([
90
+ 'owner_did',
91
+ 'connector_name',
92
+ 'model',
93
+ 'account_key',
94
+ 'source_id'
95
+ ]).doUpdateSet((eb)=>({
96
+ repair_generation: eb('kubun_connector_projection_watch.repair_generation', '+', 1),
97
+ verification_attempts: 0,
98
+ verification_status: 'pending_verification',
99
+ observed_winner_digest: input.currentWinnerDigest,
100
+ verified_state_digest: null,
101
+ next_attempt_at: now,
102
+ claimed_until: null,
103
+ defer_reason: null,
104
+ version: eb('kubun_connector_projection_watch.version', '+', 1),
105
+ updated_at: now
106
+ }))).execute();
107
+ return true;
108
+ });
147
109
  },
148
- async consumePendingAuth (state) {
149
- // Atomic single-use read: DELETE ... RETURNING removes and returns the row
150
- // in one statement so concurrent callbacks cannot both consume the state.
151
- const row = await db.deleteFrom('kubun_connector_pending_auth').where('state', '=', state).returningAll().executeTakeFirst();
152
- if (row == null) return null;
153
- return {
154
- state: row.state,
155
- codeVerifier: row.code_verifier,
156
- provider: row.provider,
157
- ownerDID: row.owner_did,
158
- ownerWrappableDID: row.owner_wrappable_did,
159
- controllerWrappableDID: row.controller_wrappable_did,
160
- createdAt: row.created_at,
161
- redirectURL: row.redirect_url,
162
- // ParseJSONResultsPlugin may have already parsed this column, same as
163
- // `checkpoint` in `rowToSyncState`.
164
- scopes: typeof row.scopes === 'string' ? JSON.parse(row.scopes) : row.scopes,
165
- expiresAt: row.expires_at
166
- };
110
+ ...createProjectionWatchesMethods(context),
111
+ async requestRepull (identity, opts) {
112
+ // Thin wrapper: re-arm only. The caller is responsible for scheduling
113
+ // the actual sweep tick that picks this back up via
114
+ // `listDueOperations` — this method's whole job is to make the watch
115
+ // due again (unconditionally opening a new generation, same as any
116
+ // other `'manual'` arm).
117
+ await api.armWatch(identity, 'manual', {
118
+ dueAt: opts.dueAt
119
+ });
167
120
  },
168
- async deleteExpiredPendingAuth (cutoffMs) {
169
- // Strict `<` matches @kokuin/oauth `completeAuthorization`, which treats a
170
- // record as expired only when `expiresAt < now`. A record at exactly
171
- // `expiresAt === cutoffMs` is therefore still consumable and is kept here
172
- // (the next sweep, once `now > expiresAt`, removes it). This intentionally
173
- // keeps one more record than the `PendingAuthStore.deleteExpired` port doc
174
- // ("at or before nowMs") so the sweep and the consume check agree on the
175
- // expiry boundary.
176
- await db.deleteFrom('kubun_connector_pending_auth').where('expires_at', '<', cutoffMs).execute();
177
- }
121
+ ...createRetentionMethods(context)
178
122
  };
123
+ return api;
179
124
  }
@@ -1,4 +1,9 @@
1
1
  import type { StoreDefinition } from '@kubun/db';
2
- import type { ConnectorStoreAPI } from './api.js';
2
+ import type { ConnectorStoreAPI, ConnectorStoreDeps } from './api.js';
3
3
  import type { ConnectorTables } from './tables.js';
4
- export declare const connectorStoreDefinition: StoreDefinition<ConnectorTables, ConnectorStoreAPI>;
4
+ /**
5
+ * The connector store's definition, its API bound to `deps`:
6
+ * `KubunDB.register`/`getStore` call `createAPI(db, adapter)` with exactly two
7
+ * arguments, so the dependencies close over here.
8
+ */
9
+ export declare function createConnectorStoreDefinition(deps: ConnectorStoreDeps): StoreDefinition<ConnectorTables, ConnectorStoreAPI>;
package/lib/definition.js CHANGED
@@ -1,7 +1,13 @@
1
1
  import { createConnectorStore } from './api.js';
2
2
  import { getConnectorMigrations } from './migrations.js';
3
- export const connectorStoreDefinition = {
4
- name: 'connector',
5
- migrations: getConnectorMigrations,
6
- createAPI: createConnectorStore
7
- };
3
+ /**
4
+ * The connector store's definition, its API bound to `deps`:
5
+ * `KubunDB.register`/`getStore` call `createAPI(db, adapter)` with exactly two
6
+ * arguments, so the dependencies close over here.
7
+ */ export function createConnectorStoreDefinition(deps) {
8
+ return {
9
+ name: 'connector',
10
+ migrations: getConnectorMigrations,
11
+ createAPI: (db, adapter)=>createConnectorStore(db, adapter, deps)
12
+ };
13
+ }
@@ -0,0 +1,8 @@
1
+ import type { ConnectorStoreAPI } from './api-types.js';
2
+ import type { ConnectorStoreContext } from './store-context.js';
3
+ export declare class ConnectorDocumentSweepError extends Error {
4
+ get code(): 'CONNECTOR_SWEEP_UNFINISHED_OPERATION';
5
+ constructor(documentID: string);
6
+ }
7
+ export type DocumentSweepMethods = Pick<ConnectorStoreAPI, 'hasDocumentConnectorWork' | 'sweepDocumentRows'>;
8
+ export declare function createDocumentSweepMethods({ db }: ConnectorStoreContext): DocumentSweepMethods;
@@ -0,0 +1,46 @@
1
+ const OBSERVATION_TYPE = 'connector.observation';
2
+ /** States an operation can still leave: its row is the idempotency record of a provider write. */ const UNFINISHED = [
3
+ 'attempted',
4
+ 'reconcile_only'
5
+ ];
6
+ const FINISHED = [
7
+ 'committed',
8
+ 'dead'
9
+ ];
10
+ export class ConnectorDocumentSweepError extends Error {
11
+ get code() {
12
+ return 'CONNECTOR_SWEEP_UNFINISHED_OPERATION';
13
+ }
14
+ constructor(documentID){
15
+ super(`Document ${documentID} has an unfinished connector operation`);
16
+ this.name = 'ConnectorDocumentSweepError';
17
+ }
18
+ }
19
+ // Plain statements on `db`, never `db.transaction()`: both run inside the caller's sweep
20
+ // transaction, where a nested transaction is refused.
21
+ export function createDocumentSweepMethods({ db }) {
22
+ const unfinishedOperation = async (documentID)=>{
23
+ const row = await db.selectFrom('kubun_connector_operation').select('operation_id').where('document_id', '=', documentID).where('state', 'in', UNFINISHED).limit(1).executeTakeFirst();
24
+ return row !== undefined;
25
+ };
26
+ const anyRow = async (table, documentID)=>{
27
+ const row = await db.selectFrom(table).select('document_id').where('document_id', '=', documentID).limit(1).executeTakeFirst();
28
+ return row !== undefined;
29
+ };
30
+ return {
31
+ async hasDocumentConnectorWork (documentID) {
32
+ return await anyRow('kubun_connector_projection_watch', documentID) || await anyRow('kubun_connector_sweep_candidate', documentID) || await anyRow('kubun_connector_entity_trim', documentID) || await unfinishedOperation(documentID);
33
+ },
34
+ async sweepDocumentRows (documentID) {
35
+ // Deleting it would let a replayed request write to the provider again.
36
+ if (await unfinishedOperation(documentID)) throw new ConnectorDocumentSweepError(documentID);
37
+ // Every copy, whatever its owner or signer: the document ID names one document.
38
+ await db.deleteFrom('kubun_connector_observation_records').where('type', '=', OBSERVATION_TYPE).where('id', '=', documentID).execute();
39
+ await db.deleteFrom('kubun_connector_observation_repair').where('document_id', '=', documentID).execute();
40
+ await db.deleteFrom('kubun_connector_entity_trim').where('document_id', '=', documentID).execute();
41
+ await db.deleteFrom('kubun_connector_projection_watch').where('document_id', '=', documentID).execute();
42
+ await db.deleteFrom('kubun_connector_sweep_candidate').where('document_id', '=', documentID).execute();
43
+ await db.deleteFrom('kubun_connector_operation').where('document_id', '=', documentID).where('state', 'in', FINISHED).execute();
44
+ }
45
+ };
46
+ }
@@ -0,0 +1,4 @@
1
+ import type { ConnectorStoreAPI } from './api-types.js';
2
+ import type { ConnectorStoreContext } from './store-context.js';
3
+ export type EntityTrimMethods = Pick<ConnectorStoreAPI, 'getEntityTrim' | 'setEntityTrim' | 'clearEntityTrim'>;
4
+ export declare function createEntityTrimMethods(context: ConnectorStoreContext): EntityTrimMethods;
@@ -0,0 +1,36 @@
1
+ import { decodeJSONColumn } from './serialization.js';
2
+ export function createEntityTrimMethods(context) {
3
+ const { db } = context;
4
+ const methods = {
5
+ // --- Entity trim records ---
6
+ async getEntityTrim (documentID) {
7
+ const row = await db.selectFrom('kubun_connector_entity_trim').selectAll().where('document_id', '=', documentID).executeTakeFirst();
8
+ if (row == null) return null;
9
+ return {
10
+ documentID: row.document_id,
11
+ ownerDID: row.owner_did,
12
+ connectorName: row.connector_name,
13
+ trimmedFields: decodeJSONColumn(row.trimmed_fields),
14
+ updatedAt: row.updated_at
15
+ };
16
+ },
17
+ async setEntityTrim (input) {
18
+ const trimmedFields = JSON.stringify(input.trimmedFields);
19
+ const updatedAt = Date.now();
20
+ await db.insertInto('kubun_connector_entity_trim').values({
21
+ document_id: input.documentID,
22
+ owner_did: input.ownerDID,
23
+ connector_name: input.connectorName,
24
+ trimmed_fields: trimmedFields,
25
+ updated_at: updatedAt
26
+ }).onConflict((oc)=>oc.column('document_id').doUpdateSet({
27
+ trimmed_fields: trimmedFields,
28
+ updated_at: updatedAt
29
+ })).execute();
30
+ },
31
+ async clearEntityTrim (documentID) {
32
+ await db.deleteFrom('kubun_connector_entity_trim').where('document_id', '=', documentID).execute();
33
+ }
34
+ };
35
+ return methods;
36
+ }
package/lib/index.d.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import type { StoreProvider } from '@kubun/db';
2
2
  import type { ConnectorStoreAPI } from './api.js';
3
- export type { ConnectorStoreAPI, PendingAuthRecord, SyncProgressCASCurrent, SyncProgressCASResult, SyncProgressRecompute, SyncState, SyncStateEntry, SyncStatus, } from './api.js';
3
+ export type { AcquireLeaseParams, ArmWatchOptions, ArmWatchOutcome, ArmWatchReason, ClaimOperationInput, ConnectorOperation, ConnectorOperationOp, ConnectorOperationState, ConnectorStoreAPI, ConnectorStoreDeps, DeferReason, EntityTrimInput, EntityTrimRecord, FinalizeOperationInput, PendingAuthRecord, ProjectionWatch, ProjectionWatchIdentity, ProjectionWatchVerificationStatus, ReadWinner, ReadWinners, RecordOperationInput, RecordVerificationInput, RecordVerificationOutcome, ReleaseLeaseParams, RenewLeaseParams, SweepApplyFailureInput, SweepCandidate, SweepCandidateInput, SweepCandidateKey, SweepKey, SweepOutcomeInput, SweepReceipt, SweepReceiptKey, SyncProgressCASCurrent, SyncProgressCASResult, SyncProgressRecompute, SyncState, SyncStateEntry, SyncStatus, TrackedOperationFilter, UpdateSyncProgressParams, } from './api.js';
4
4
  export { createConnectorStore } from './api.js';
5
- export { connectorStoreDefinition } from './definition.js';
5
+ export { createConnectorStoreDefinition } from './definition.js';
6
+ export { ConnectorDocumentSweepError } from './document-sweep.js';
7
+ export { ConnectorObservationRecordError, ConnectorObservationRecordTypeError, connectorObservationRecordAdapter, type ObservationRepair, type ObservationRepairWrite, type ObservationRepairWritePage, observationProviderOfRecords, type WrittenObservationRepair, } from './observation-records.js';
8
+ export { MAX_SWEEP_APPLY_ATTEMPTS } from './sweep-state.js';
6
9
  export declare const CONNECTOR_STORE: "connector";
7
10
  export declare function getConnectorStore(provider: StoreProvider): Promise<ConnectorStoreAPI>;
8
- export type { ConnectorSyncState, ConnectorSyncStateTable, ConnectorTables, } from './tables.js';
11
+ export type { ConnectorEntityTrim, ConnectorEntityTrimTable, ConnectorSyncState, ConnectorSyncStateTable, ConnectorTables, } from './tables.js';
package/lib/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  export { createConnectorStore } from './api.js';
2
- export { connectorStoreDefinition } from './definition.js';
2
+ export { createConnectorStoreDefinition } from './definition.js';
3
+ export { ConnectorDocumentSweepError } from './document-sweep.js';
4
+ export { ConnectorObservationRecordError, ConnectorObservationRecordTypeError, connectorObservationRecordAdapter, observationProviderOfRecords } from './observation-records.js';
5
+ export { MAX_SWEEP_APPLY_ATTEMPTS } from './sweep-state.js';
3
6
  export const CONNECTOR_STORE = 'connector';
4
7
  export function getConnectorStore(provider) {
5
8
  return provider.getStore(CONNECTOR_STORE);
package/lib/migrations.js CHANGED
@@ -2,6 +2,30 @@ export function getConnectorMigrations(ctx) {
2
2
  const t = ctx.types;
3
3
  const init = {
4
4
  async up (db) {
5
+ await db.schema.createTable('kubun_connector_sweep_receipt').ifNotExists().addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('connector_name', t.text, (col)=>col.notNull()).addColumn('partition', t.text, (col)=>col.notNull()).addColumn('provenance', t.text, (col)=>col.notNull()).addColumn('sequence', t.bigint, (col)=>col.notNull()).addColumn('model_id', t.text, (col)=>col.notNull()).addColumn('scope', t.text, (col)=>col.notNull()).addColumn('keep', t.text, (col)=>col.notNull()).addColumn('source_fetch_hlc', t.text, (col)=>col.notNull()).addColumn('predicate', t.text).addColumn('kind', t.text, (col)=>col.notNull()).addColumn('complete', 'integer', (col)=>col.notNull()).addColumn('created_at', t.bigint, (col)=>col.notNull()).addColumn('committed_at', t.bigint).addPrimaryKeyConstraint('kubun_connector_sweep_receipt_pkey', [
6
+ 'owner_did',
7
+ 'connector_name',
8
+ 'partition',
9
+ 'provenance'
10
+ ]).execute();
11
+ await db.schema.createTable('kubun_connector_sweep_candidate').ifNotExists().addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('connector_name', t.text, (col)=>col.notNull()).addColumn('partition', t.text, (col)=>col.notNull()).addColumn('provenance', t.text, (col)=>col.notNull()).addColumn('document_id', t.text, (col)=>col.notNull()).addColumn('source_id', t.text, (col)=>col.notNull()).addColumn('model_id', t.text, (col)=>col.notNull()).addColumn('state', t.text, (col)=>col.notNull()).addColumn('attempts', 'integer', (col)=>col.notNull()).addColumn('apply_failures', 'integer', (col)=>col.notNull().defaultTo(0)).addColumn('last_error', t.text).addColumn('next_attempt_at', t.bigint, (col)=>col.notNull().defaultTo(0)).addColumn('updated_at', t.bigint, (col)=>col.notNull()).addPrimaryKeyConstraint('kubun_connector_sweep_candidate_pkey', [
12
+ 'owner_did',
13
+ 'connector_name',
14
+ 'partition',
15
+ 'provenance',
16
+ 'document_id'
17
+ ]).execute();
18
+ await db.schema.createIndex('kubun_connector_sweep_candidate_due_idx').ifNotExists().on('kubun_connector_sweep_candidate').columns([
19
+ 'owner_did',
20
+ 'connector_name',
21
+ 'state',
22
+ 'next_attempt_at'
23
+ ]).execute();
24
+ await db.schema.createTable('kubun_connector_sweep_checkpoint').ifNotExists().addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('connector_name', t.text, (col)=>col.notNull()).addColumn('partition', t.text, (col)=>col.notNull()).addColumn('provenance', t.text, (col)=>col.notNull()).addColumn('receipt_sequence', t.bigint, (col)=>col.notNull()).addColumn('updated_at', t.bigint, (col)=>col.notNull()).addPrimaryKeyConstraint('kubun_connector_sweep_checkpoint_pkey', [
25
+ 'owner_did',
26
+ 'connector_name',
27
+ 'partition'
28
+ ]).execute();
5
29
  // Connector sync state
6
30
  await db.schema.createTable('kubun_connector_sync_state').ifNotExists().addColumn('connector_name', t.text, (col)=>col.notNull()).addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('checkpoint', t.text).addColumn('last_synced_at', t.text, (col)=>col.notNull()).addColumn('entity_count', 'integer', (col)=>col.notNull().defaultTo(0)).addColumn('status', t.text, (col)=>col.notNull()).addColumn('error', t.text)// Lease expiry for atomic sync-claim coordination. ms-epoch (Date.now)
7
31
  // overflows int32 on Postgres; bigint holds it and the adapter's int8
@@ -29,12 +53,106 @@ export function getConnectorMigrations(ctx) {
29
53
  // time, both needed to complete the token exchange and to fill in
30
54
  // `PendingAuthRecord`'s required fields.
31
55
  .addColumn('redirect_url', t.text, (col)=>col.notNull())// JSON-encoded `Array<string>`.
32
- .addColumn('scopes', t.text, (col)=>col.notNull())// ms-epoch (Date.now) overflows int32 on Postgres; bigint holds it and
56
+ .addColumn('scopes', t.text, (col)=>col.notNull())// 1 when start-auth requested only the missing scopes, else 0.
57
+ .addColumn('is_incremental', 'integer', (col)=>col.notNull())// ms-epoch (Date.now) overflows int32 on Postgres; bigint holds it and
33
58
  // the adapter's int8 parser reads it back as a JS number. The sweep in
34
59
  // `deleteExpiredPendingAuth` keys off this column, not `created_at`.
35
60
  .addColumn('expires_at', t.bigint, (col)=>col.notNull()).execute();
61
+ // Connector write-back operation envelope: one row per attempted create/
62
+ // update, keyed by the caller's idempotency key so a replayed request
63
+ // (crash recovery, retry) converges on the same row instead of double-
64
+ // writing to the provider.
65
+ await db.schema.createTable('kubun_connector_operation').ifNotExists().addColumn('caller_did', t.text, (col)=>col.notNull()).addColumn('operation_id', t.text, (col)=>col.notNull())// An opaque random key assigned at claim time, the operation's public
66
+ // identity (its GraphQL node id). Kept apart from the caller-supplied
67
+ // `operation_id`, which is an idempotency key and says nothing about
68
+ // who may look the operation up.
69
+ .addColumn('node_key', t.text, (col)=>col.notNull()).addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('connector_name', t.text, (col)=>col.notNull()).addColumn('model', t.text, (col)=>col.notNull()).addColumn('op', t.text, (col)=>col.notNull()).addColumn('account_key', t.text, (col)=>col.notNull()).addColumn('target', t.text, (col)=>col.notNull()).addColumn('source_id', t.text).addColumn('deterministic_event_id', t.text, (col)=>col.notNull()).addColumn('payload_hash', t.text, (col)=>col.notNull()).addColumn('reissue_intent', t.text, (col)=>col.notNull()).addColumn('provider_etag', t.text).addColumn('document_id', t.text).addColumn('result_entity', t.text).addColumn('state', t.text, (col)=>col.notNull()).addColumn('attempts', 'integer', (col)=>col.notNull().defaultTo(0))// ms-epoch (Date.now) overflows int32 on Postgres; bigint holds it and
70
+ // the adapter's int8 parser reads it back as a JS number.
71
+ .addColumn('next_attempt_at', t.bigint)// Why `next_attempt_at` was pushed out without spending an attempt, when
72
+ // that reason can lapse on its own: 'offline' is made due again as soon
73
+ // as the device reconnects, 'auth' as soon as the owner authorizes.
74
+ // Null for a backoff or any other deferral.
75
+ .addColumn('defer_reason', t.text)// The run that started a provider write in the current attempt (a
76
+ // per-run marker), cleared when a new attempt begins: a re-run of the
77
+ // same attempt cannot claim no request was ever sent.
78
+ .addColumn('send_marker', t.text).addColumn('last_kind', t.text).addColumn('last_http_status', 'integer').addColumn('version', 'integer', (col)=>col.notNull().defaultTo(0)).addColumn('incarnation', t.text).addColumn('lease_token', t.text)// ms-epoch (Date.now) overflows int32 on Postgres; bigint holds it and
79
+ // the adapter's int8 parser reads it back as a JS number.
80
+ .addColumn('lease_expires_at', t.bigint)// ms-epoch (Date.now) overflows int32 on Postgres; bigint holds it and
81
+ // the adapter's int8 parser reads it back as a JS number.
82
+ .addColumn('created_at', t.bigint, (col)=>col.notNull()).addColumn('updated_at', t.bigint, (col)=>col.notNull()).addPrimaryKeyConstraint('kubun_connector_operation_pkey', [
83
+ 'caller_did',
84
+ 'operation_id'
85
+ ]).execute();
86
+ await db.schema.createIndex('uq_connector_operation_node_key').ifNotExists().on('kubun_connector_operation').column('node_key').unique().execute();
87
+ // The operations of a document (its write state), read by document id.
88
+ await db.schema.createIndex('idx_connector_operation_document').ifNotExists().on('kubun_connector_operation').column('document_id').execute();
89
+ // The operations an owner may see (`listTrackedOperations({ visibleTo })`
90
+ // matches the caller through the primary key, the owner through this).
91
+ await db.schema.createIndex('idx_connector_operation_owner').ifNotExists().on('kubun_connector_operation').column('owner_did').execute();
92
+ // The retention cleanup's candidates: finished operations by last update.
93
+ await db.schema.createIndex('idx_connector_operation_state_updated').ifNotExists().on('kubun_connector_operation').columns([
94
+ 'state',
95
+ 'updated_at'
96
+ ]).execute();
97
+ // Connector projection verify/repair watch: one row per ingested
98
+ // provider document being tracked for eventual verification against the
99
+ // local converged state, with a unique ingress index so the same
100
+ // (owner, model, document) is only ever watched under one source.
101
+ await db.schema.createTable('kubun_connector_projection_watch').ifNotExists().addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('connector_name', t.text, (col)=>col.notNull()).addColumn('model', t.text, (col)=>col.notNull()).addColumn('account_key', t.text, (col)=>col.notNull()).addColumn('source_id', t.text, (col)=>col.notNull()).addColumn('document_id', t.text, (col)=>col.notNull()).addColumn('verified_state_digest', t.text).addColumn('observed_winner_digest', t.text).addColumn('verification_status', t.text, (col)=>col.notNull()).addColumn('verification_attempts', 'integer', (col)=>col.notNull().defaultTo(0)).addColumn('repair_generation', 'integer', (col)=>col.notNull().defaultTo(0))// ms-epoch (Date.now) overflows int32 on Postgres; bigint holds it and
102
+ // the adapter's int8 parser reads it back as a JS number.
103
+ .addColumn('next_attempt_at', t.bigint)// Until when a verifier holds the current attempt (`claimWatchAttempt`);
104
+ // kept apart from `next_attempt_at`, which an ingress coalesce moves.
105
+ .addColumn('claimed_until', t.bigint)// As on the operation table: 'offline' when the pending attempt was
106
+ // deferred only for being offline.
107
+ .addColumn('defer_reason', t.text).addColumn('version', 'integer', (col)=>col.notNull().defaultTo(0))// ms-epoch (Date.now) overflows int32 on Postgres; bigint holds it and
108
+ // the adapter's int8 parser reads it back as a JS number.
109
+ .addColumn('created_at', t.bigint, (col)=>col.notNull()).addColumn('updated_at', t.bigint, (col)=>col.notNull()).addPrimaryKeyConstraint('kubun_connector_projection_watch_pkey', [
110
+ 'owner_did',
111
+ 'connector_name',
112
+ 'model',
113
+ 'account_key',
114
+ 'source_id'
115
+ ]).execute();
116
+ // Ingress index: within one owner+model, a given provider document may
117
+ // only be watched under a single source. A write-back finalized for the
118
+ // document under another source supersedes the earlier row.
119
+ await db.schema.createIndex('uq_connector_watch_ingress').ifNotExists().on('kubun_connector_projection_watch').columns([
120
+ 'owner_did',
121
+ 'model',
122
+ 'document_id'
123
+ ]).unique().execute();
124
+ // Document lookup: an applied write names only its document id
125
+ // (`getWatchByDocument`), which the ingress index cannot serve alone.
126
+ await db.schema.createIndex('idx_connector_watch_document').ifNotExists().on('kubun_connector_projection_watch').column('document_id').execute();
127
+ await db.schema.createTable('kubun_connector_observation_records').ifNotExists().addColumn('type', t.text, (col)=>col.notNull()).addColumn('owner', t.text, (col)=>col.notNull()).addColumn('id', t.text, (col)=>col.notNull()).addColumn('op_hash', t.text, (col)=>col.notNull()).addColumn('hlc', t.text, (col)=>col.notNull()).addColumn('op_jwt', t.text, (col)=>col.notNull()).addColumn('payload', t.binary).addPrimaryKeyConstraint('kubun_connector_observation_records_pkey', [
128
+ 'type',
129
+ 'owner',
130
+ 'id',
131
+ 'op_hash'
132
+ ]).execute();
133
+ await db.schema.createTable('kubun_connector_observation_repair').ifNotExists().addColumn('document_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('owner', t.text, (col)=>col.notNull()).addColumn('fetch_hlc', t.text, (col)=>col.notNull()).addColumn('decision_hlc', t.text, (col)=>col.notNull()).addColumn('present', 'integer', (col)=>col.notNull()).addColumn('value_digest', t.text)// When the obligation was last written, so a reader can take only the
134
+ // ones written since it last looked.
135
+ .addColumn('written_at', t.bigint, (col)=>col.notNull()).execute();
136
+ await db.schema.createIndex('idx_connector_observation_repair_written').ifNotExists().on('kubun_connector_observation_repair').columns([
137
+ 'written_at',
138
+ 'document_id'
139
+ ]).execute();
140
+ // The source values fitted to a model's caps when a document was last
141
+ // written, keyed by that document. Replaced on every write that trims,
142
+ // removed by one that does not.
143
+ await db.schema.createTable('kubun_connector_entity_trim').ifNotExists().addColumn('document_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('connector_name', t.text, (col)=>col.notNull()).addColumn('trimmed_fields', t.text, (col)=>col.notNull())// ms-epoch (Date.now) overflows int32 on Postgres; bigint holds it and
144
+ // the adapter's int8 parser reads it back as a JS number.
145
+ .addColumn('updated_at', t.bigint, (col)=>col.notNull()).execute();
36
146
  },
37
147
  async down (db) {
148
+ await db.schema.dropTable('kubun_connector_sweep_checkpoint').ifExists().execute();
149
+ await db.schema.dropTable('kubun_connector_sweep_candidate').ifExists().execute();
150
+ await db.schema.dropTable('kubun_connector_sweep_receipt').ifExists().execute();
151
+ await db.schema.dropTable('kubun_connector_observation_repair').ifExists().execute();
152
+ await db.schema.dropTable('kubun_connector_observation_records').ifExists().execute();
153
+ await db.schema.dropTable('kubun_connector_entity_trim').ifExists().execute();
154
+ await db.schema.dropTable('kubun_connector_projection_watch').ifExists().execute();
155
+ await db.schema.dropTable('kubun_connector_operation').ifExists().execute();
38
156
  await db.schema.dropTable('kubun_connector_pending_auth').ifExists().execute();
39
157
  await db.schema.dropTable('kubun_connector_sync_state').ifExists().execute();
40
158
  }