@kubun/store-connector 0.17.2 → 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-types.d.ts +434 -0
- package/lib/api-types.js +2 -0
- package/lib/api.d.ts +2 -324
- package/lib/api.js +26 -880
- package/lib/document-sweep.d.ts +8 -0
- package/lib/document-sweep.js +46 -0
- package/lib/entity-trim.d.ts +4 -0
- package/lib/entity-trim.js +36 -0
- package/lib/index.d.ts +5 -2
- package/lib/index.js +3 -0
- package/lib/migrations.js +51 -1
- package/lib/observation-records.d.ts +52 -0
- package/lib/observation-records.js +310 -0
- package/lib/operations.d.ts +4 -0
- package/lib/operations.js +223 -0
- package/lib/pending-auth.d.ts +4 -0
- package/lib/pending-auth.js +69 -0
- package/lib/projection-watches.d.ts +4 -0
- package/lib/projection-watches.js +265 -0
- package/lib/retention.d.ts +4 -0
- package/lib/retention.js +193 -0
- package/lib/serialization.d.ts +63 -0
- package/lib/serialization.js +79 -0
- package/lib/store-context.d.ts +11 -0
- package/lib/store-context.js +1 -0
- package/lib/sweep-state.d.ts +5 -0
- package/lib/sweep-state.js +180 -0
- package/lib/sync-state.d.ts +4 -0
- package/lib/sync-state.js +107 -0
- package/lib/tables.d.ts +73 -0
- package/package.json +13 -7
|
@@ -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 { ArmWatchOptions, ArmWatchOutcome, ArmWatchReason, ClaimOperationInput, ConnectorOperation, ConnectorOperationOp, ConnectorOperationState, ConnectorStoreAPI, ConnectorStoreDeps, DeferReason, FinalizeOperationInput, PendingAuthRecord, ProjectionWatch, ProjectionWatchIdentity, ProjectionWatchVerificationStatus, ReadWinner, RecordOperationInput, RecordVerificationInput, RecordVerificationOutcome, SyncProgressCASCurrent, SyncProgressCASResult, SyncProgressRecompute, SyncState, SyncStateEntry, SyncStatus, TrackedOperationFilter, } 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
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
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,7 +53,8 @@ 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())//
|
|
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();
|
|
@@ -99,8 +124,33 @@ export function getConnectorMigrations(ctx) {
|
|
|
99
124
|
// Document lookup: an applied write names only its document id
|
|
100
125
|
// (`getWatchByDocument`), which the ingress index cannot serve alone.
|
|
101
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();
|
|
102
146
|
},
|
|
103
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();
|
|
104
154
|
await db.schema.dropTable('kubun_connector_projection_watch').ifExists().execute();
|
|
105
155
|
await db.schema.dropTable('kubun_connector_operation').ifExists().execute();
|
|
106
156
|
await db.schema.dropTable('kubun_connector_pending_auth').ifExists().execute();
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { MethodRegistry } from '@kokuin/token';
|
|
2
|
+
import type { StoreProvider } from '@kubun/db';
|
|
3
|
+
import { type Adapter } from '@kubun/db-adapter';
|
|
4
|
+
import { type RecordDecision, type RecordKey, type RecordStoreAdapter, type SignedRecord } from '@kubun/records';
|
|
5
|
+
import { type Kysely } from 'kysely';
|
|
6
|
+
import type { ConnectorTables } from './tables.js';
|
|
7
|
+
export declare function observationProviderOfRecords(records: RecordStoreAdapter): StoreProvider | null;
|
|
8
|
+
export declare class ConnectorObservationRecordTypeError extends Error {
|
|
9
|
+
constructor(type: string);
|
|
10
|
+
}
|
|
11
|
+
export declare class ConnectorObservationRecordError extends Error {
|
|
12
|
+
constructor(message: string);
|
|
13
|
+
}
|
|
14
|
+
export type ObservationRepair = {
|
|
15
|
+
documentID: string;
|
|
16
|
+
owner: string;
|
|
17
|
+
fetchHLC: string;
|
|
18
|
+
decisionHLC: string;
|
|
19
|
+
present: boolean;
|
|
20
|
+
valueDigest: string | null;
|
|
21
|
+
};
|
|
22
|
+
/** Where a reader of obligation writes stands: the last write it took. */
|
|
23
|
+
export type ObservationRepairWrite = {
|
|
24
|
+
writtenAt: number;
|
|
25
|
+
documentID: string;
|
|
26
|
+
};
|
|
27
|
+
/** An obligation with the time it was last written. */
|
|
28
|
+
export type WrittenObservationRepair = ObservationRepair & {
|
|
29
|
+
writtenAt: number;
|
|
30
|
+
};
|
|
31
|
+
export type ObservationRepairWritePage = {
|
|
32
|
+
after: ObservationRepairWrite | null;
|
|
33
|
+
limit: number;
|
|
34
|
+
};
|
|
35
|
+
export type ObservationRecordMethods = {
|
|
36
|
+
getObservationRecordDecision(key: RecordKey, methods?: MethodRegistry): Promise<RecordDecision | null>;
|
|
37
|
+
/** The decisions of many keys read together; a key with no valid copy is absent. */
|
|
38
|
+
getObservationRecordDecisions(keys: ReadonlyArray<RecordKey>, methods?: MethodRegistry): Promise<Array<RecordDecision>>;
|
|
39
|
+
listObservationRecordDecisions(types: ReadonlyArray<string>, methods?: MethodRegistry): Promise<Array<RecordDecision>>;
|
|
40
|
+
putObservationRecordCopy(record: SignedRecord, methods?: MethodRegistry): Promise<void>;
|
|
41
|
+
pruneObservationRecordCopy(key: RecordKey, signerDID: string, methods?: MethodRegistry): Promise<void>;
|
|
42
|
+
lockObservationRecordKeys(keys: ReadonlyArray<string>): Promise<void>;
|
|
43
|
+
getObservationRepair(documentID: string): Promise<ObservationRepair | null>;
|
|
44
|
+
/** The obligations of many documents read together; a document with none is absent. */
|
|
45
|
+
getObservationRepairs(documentIDs: ReadonlyArray<string>): Promise<Map<string, ObservationRepair>>;
|
|
46
|
+
/** One page of repair obligations in write order, written after `after`. */
|
|
47
|
+
listObservationRepairsWrittenSince(page: ObservationRepairWritePage): Promise<Array<WrittenObservationRepair>>;
|
|
48
|
+
putObservationRepair(repair: ObservationRepair): Promise<void>;
|
|
49
|
+
clearObservationRepair(documentID: string): Promise<void>;
|
|
50
|
+
};
|
|
51
|
+
export declare function createObservationRecordMethods(db: Kysely<ConnectorTables>, adapter: Adapter): ObservationRecordMethods;
|
|
52
|
+
export declare function connectorObservationRecordAdapter(provider: StoreProvider, methods?: MethodRegistry): Promise<RecordStoreAdapter>;
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { AbstractPostgresAdapter, chunked } from '@kubun/db-adapter';
|
|
2
|
+
import { compareRecordDecisionHLC, parseCanonicalHLC, recordClaimDigest, recordDecisionFromVerifiedCopies, recordKeyText, recordPayloadMatches, recordSignerKey, verifySignedRecord } from '@kubun/records';
|
|
3
|
+
import { sql } from 'kysely';
|
|
4
|
+
const TYPE = 'connector.observation';
|
|
5
|
+
const providers = new WeakMap();
|
|
6
|
+
export function observationProviderOfRecords(records) {
|
|
7
|
+
return providers.get(records) ?? null;
|
|
8
|
+
}
|
|
9
|
+
export class ConnectorObservationRecordTypeError extends Error {
|
|
10
|
+
constructor(type){
|
|
11
|
+
super(`Unsupported connector observation record type: ${type}`);
|
|
12
|
+
this.name = 'ConnectorObservationRecordTypeError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class ConnectorObservationRecordError extends Error {
|
|
16
|
+
constructor(message){
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'ConnectorObservationRecordError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function requireType(type) {
|
|
22
|
+
if (type !== TYPE) throw new ConnectorObservationRecordTypeError(type);
|
|
23
|
+
}
|
|
24
|
+
function lockKey(key) {
|
|
25
|
+
return JSON.stringify([
|
|
26
|
+
key.type,
|
|
27
|
+
JSON.stringify([
|
|
28
|
+
key.owner,
|
|
29
|
+
key.id
|
|
30
|
+
])
|
|
31
|
+
]);
|
|
32
|
+
}
|
|
33
|
+
function copyFromRow(row) {
|
|
34
|
+
return {
|
|
35
|
+
type: row.type,
|
|
36
|
+
owner: row.owner,
|
|
37
|
+
id: row.id,
|
|
38
|
+
hlc: row.hlc,
|
|
39
|
+
opJWT: row.op_jwt,
|
|
40
|
+
opHash: row.op_hash,
|
|
41
|
+
payload: row.payload === null ? null : Uint8Array.from(row.payload)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
async function verifiedRows(rows, methods) {
|
|
45
|
+
const result = [];
|
|
46
|
+
for (const row of rows){
|
|
47
|
+
const copy = copyFromRow(row);
|
|
48
|
+
const op = await verifySignedRecord(copy, methods);
|
|
49
|
+
if (op === null || !recordPayloadMatches(copy, op.claim)) continue;
|
|
50
|
+
if (typeof op.claim.meta.version !== 'string' || parseCanonicalHLC(op.claim.meta.version) === null) continue;
|
|
51
|
+
const signerKey = recordSignerKey(op.issuer, copy.opJWT);
|
|
52
|
+
if (signerKey !== null) result.push({
|
|
53
|
+
row,
|
|
54
|
+
copy,
|
|
55
|
+
op,
|
|
56
|
+
signerKey
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
function decision(key, rows) {
|
|
62
|
+
return recordDecisionFromVerifiedCopies(key, rows, 'version');
|
|
63
|
+
}
|
|
64
|
+
function repairFromRow(row) {
|
|
65
|
+
return {
|
|
66
|
+
documentID: row.document_id,
|
|
67
|
+
owner: row.owner,
|
|
68
|
+
fetchHLC: row.fetch_hlc,
|
|
69
|
+
decisionHLC: row.decision_hlc,
|
|
70
|
+
present: row.present === 1,
|
|
71
|
+
valueDigest: row.value_digest
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export function createObservationRecordMethods(db, adapter) {
|
|
75
|
+
const rowsFor = async (key, tx = db)=>await tx.selectFrom('kubun_connector_observation_records').selectAll().where('type', '=', key.type).where('owner', '=', key.owner).where('id', '=', key.id).execute();
|
|
76
|
+
const lockKeys = async (tx, keys)=>{
|
|
77
|
+
if (!(adapter instanceof AbstractPostgresAdapter)) return;
|
|
78
|
+
for (const key of [
|
|
79
|
+
...new Set(keys)
|
|
80
|
+
].sort())await sql`select pg_advisory_xact_lock(hashtextextended(${key}, 0))`.execute(tx);
|
|
81
|
+
};
|
|
82
|
+
const write = async (fn)=>db.isTransaction ? await fn(db) : await db.transaction().execute(fn);
|
|
83
|
+
return {
|
|
84
|
+
async getObservationRecordDecision (key, methods) {
|
|
85
|
+
requireType(key.type);
|
|
86
|
+
return decision(key, await verifiedRows(await rowsFor(key), methods));
|
|
87
|
+
},
|
|
88
|
+
async getObservationRecordDecisions (keys, methods) {
|
|
89
|
+
for (const key of keys)requireType(key.type);
|
|
90
|
+
const wanted = new Map();
|
|
91
|
+
for (const key of keys)wanted.set(JSON.stringify([
|
|
92
|
+
key.owner,
|
|
93
|
+
key.id
|
|
94
|
+
]), key);
|
|
95
|
+
const ids = [
|
|
96
|
+
...new Set(keys.map((key)=>key.id))
|
|
97
|
+
];
|
|
98
|
+
const groups = new Map();
|
|
99
|
+
for (const chunk of chunked(ids)){
|
|
100
|
+
const rows = await db.selectFrom('kubun_connector_observation_records').selectAll().where('type', '=', TYPE).where('id', 'in', chunk).execute();
|
|
101
|
+
for (const row of rows){
|
|
102
|
+
const address = JSON.stringify([
|
|
103
|
+
row.owner,
|
|
104
|
+
row.id
|
|
105
|
+
]);
|
|
106
|
+
if (!wanted.has(address)) continue;
|
|
107
|
+
const group = groups.get(address) ?? [];
|
|
108
|
+
group.push(row);
|
|
109
|
+
groups.set(address, group);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const result = [];
|
|
113
|
+
for (const [address, group] of groups){
|
|
114
|
+
const key = wanted.get(address);
|
|
115
|
+
if (key === undefined) continue;
|
|
116
|
+
const current = decision(key, await verifiedRows(group, methods));
|
|
117
|
+
if (current !== null) result.push(current);
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
},
|
|
121
|
+
async listObservationRecordDecisions (types, methods) {
|
|
122
|
+
for (const type of types)requireType(type);
|
|
123
|
+
if (types.length === 0) return [];
|
|
124
|
+
const rows = await db.selectFrom('kubun_connector_observation_records').selectAll().where('type', '=', TYPE).orderBy('owner').orderBy('id').execute();
|
|
125
|
+
const groups = new Map();
|
|
126
|
+
for (const row of rows){
|
|
127
|
+
const address = JSON.stringify([
|
|
128
|
+
row.owner,
|
|
129
|
+
row.id
|
|
130
|
+
]);
|
|
131
|
+
const group = groups.get(address) ?? [];
|
|
132
|
+
group.push(row);
|
|
133
|
+
groups.set(address, group);
|
|
134
|
+
}
|
|
135
|
+
const result = [];
|
|
136
|
+
for (const group of groups.values()){
|
|
137
|
+
const first = group[0];
|
|
138
|
+
if (first === undefined) continue;
|
|
139
|
+
const current = decision({
|
|
140
|
+
type: TYPE,
|
|
141
|
+
owner: first.owner,
|
|
142
|
+
id: first.id
|
|
143
|
+
}, await verifiedRows(group, methods));
|
|
144
|
+
if (current !== null) result.push(current);
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
},
|
|
148
|
+
async putObservationRecordCopy (record, methods) {
|
|
149
|
+
requireType(record.type);
|
|
150
|
+
const op = await verifySignedRecord(record, methods);
|
|
151
|
+
if (op === null || !recordPayloadMatches(record, op.claim)) throw new ConnectorObservationRecordError('Observation record is not a valid signed copy');
|
|
152
|
+
const version = op.claim.meta.version;
|
|
153
|
+
if (typeof version !== 'string' || parseCanonicalHLC(version) === null) throw new ConnectorObservationRecordError('Observation decision version is invalid');
|
|
154
|
+
const signerKey = recordSignerKey(op.issuer, record.opJWT);
|
|
155
|
+
if (signerKey === null) throw new ConnectorObservationRecordError('Observation signer key is invalid');
|
|
156
|
+
const key = {
|
|
157
|
+
type: TYPE,
|
|
158
|
+
owner: record.owner,
|
|
159
|
+
id: record.id
|
|
160
|
+
};
|
|
161
|
+
await write(async (tx)=>{
|
|
162
|
+
await lockKeys(tx, [
|
|
163
|
+
lockKey(key)
|
|
164
|
+
]);
|
|
165
|
+
const rows = await rowsFor(key, tx);
|
|
166
|
+
const valid = await verifiedRows(rows, methods);
|
|
167
|
+
const current = decision(key, valid);
|
|
168
|
+
const digest = recordClaimDigest(op.claim);
|
|
169
|
+
const order = current === null ? 1 : compareRecordDecisionHLC(version, current.decisionHLC) || (digest < current.contentDigest ? -1 : digest > current.contentDigest ? 1 : 0);
|
|
170
|
+
if (order < 0) return;
|
|
171
|
+
const retained = order === 0 ? valid.filter((entry)=>entry.op.claim.meta.version === version && recordClaimDigest(entry.op.claim) === digest) : [];
|
|
172
|
+
const sameSigner = retained.find((entry)=>entry.signerKey === signerKey);
|
|
173
|
+
const incomingWinsSigner = sameSigner === undefined || record.opHash < sameSigner.copy.opHash;
|
|
174
|
+
for (const row of rows){
|
|
175
|
+
const entry = retained.find((candidate)=>candidate.row.op_hash === row.op_hash);
|
|
176
|
+
if (entry !== undefined && entry.signerKey !== signerKey) continue;
|
|
177
|
+
if (entry !== undefined && !incomingWinsSigner && entry.copy.opHash === sameSigner?.copy.opHash) continue;
|
|
178
|
+
await tx.deleteFrom('kubun_connector_observation_records').where('type', '=', TYPE).where('owner', '=', key.owner).where('id', '=', key.id).where('op_hash', '=', row.op_hash).execute();
|
|
179
|
+
}
|
|
180
|
+
if (!incomingWinsSigner) return;
|
|
181
|
+
await tx.insertInto('kubun_connector_observation_records').values({
|
|
182
|
+
type: TYPE,
|
|
183
|
+
owner: record.owner,
|
|
184
|
+
id: record.id,
|
|
185
|
+
op_hash: record.opHash,
|
|
186
|
+
hlc: record.hlc,
|
|
187
|
+
op_jwt: record.opJWT,
|
|
188
|
+
payload: record.payload === null ? null : adapter.encodeBinary(record.payload)
|
|
189
|
+
}).onConflict((oc)=>oc.columns([
|
|
190
|
+
'type',
|
|
191
|
+
'owner',
|
|
192
|
+
'id',
|
|
193
|
+
'op_hash'
|
|
194
|
+
]).doNothing()).execute();
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
async pruneObservationRecordCopy (key, signerDID, methods) {
|
|
198
|
+
requireType(key.type);
|
|
199
|
+
await write(async (tx)=>{
|
|
200
|
+
await lockKeys(tx, [
|
|
201
|
+
lockKey(key)
|
|
202
|
+
]);
|
|
203
|
+
const valid = await verifiedRows(await rowsFor(key, tx), methods);
|
|
204
|
+
if (valid.filter((entry)=>entry.op.issuer !== signerDID).length === 0) return;
|
|
205
|
+
for (const entry of valid){
|
|
206
|
+
if (entry.op.issuer !== signerDID) continue;
|
|
207
|
+
await tx.deleteFrom('kubun_connector_observation_records').where('type', '=', TYPE).where('owner', '=', key.owner).where('id', '=', key.id).where('op_hash', '=', entry.copy.opHash).execute();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
async lockObservationRecordKeys (keys) {
|
|
212
|
+
if (!db.isTransaction) throw new ConnectorObservationRecordError('Observation locks require a transaction');
|
|
213
|
+
await lockKeys(db, keys);
|
|
214
|
+
},
|
|
215
|
+
async getObservationRepair (documentID) {
|
|
216
|
+
const row = await db.selectFrom('kubun_connector_observation_repair').selectAll().where('document_id', '=', documentID).executeTakeFirst();
|
|
217
|
+
return row === undefined ? null : repairFromRow(row);
|
|
218
|
+
},
|
|
219
|
+
async getObservationRepairs (documentIDs) {
|
|
220
|
+
const repairs = new Map();
|
|
221
|
+
for (const ids of chunked([
|
|
222
|
+
...documentIDs
|
|
223
|
+
])){
|
|
224
|
+
const rows = await db.selectFrom('kubun_connector_observation_repair').selectAll().where('document_id', 'in', ids).execute();
|
|
225
|
+
for (const row of rows)repairs.set(row.document_id, repairFromRow(row));
|
|
226
|
+
}
|
|
227
|
+
return repairs;
|
|
228
|
+
},
|
|
229
|
+
async listObservationRepairsWrittenSince (page) {
|
|
230
|
+
let query = db.selectFrom('kubun_connector_observation_repair').selectAll().orderBy('written_at').orderBy('document_id').limit(page.limit);
|
|
231
|
+
const after = page.after;
|
|
232
|
+
if (after !== null) {
|
|
233
|
+
query = query.where((eb)=>eb.or([
|
|
234
|
+
eb('written_at', '>', after.writtenAt),
|
|
235
|
+
eb.and([
|
|
236
|
+
eb('written_at', '=', after.writtenAt),
|
|
237
|
+
eb('document_id', '>', after.documentID)
|
|
238
|
+
])
|
|
239
|
+
]));
|
|
240
|
+
}
|
|
241
|
+
return (await query.execute()).map((row)=>({
|
|
242
|
+
...repairFromRow(row),
|
|
243
|
+
writtenAt: Number(row.written_at)
|
|
244
|
+
}));
|
|
245
|
+
},
|
|
246
|
+
async putObservationRepair (repair) {
|
|
247
|
+
const values = {
|
|
248
|
+
owner: repair.owner,
|
|
249
|
+
fetch_hlc: repair.fetchHLC,
|
|
250
|
+
decision_hlc: repair.decisionHLC,
|
|
251
|
+
present: repair.present ? 1 : 0,
|
|
252
|
+
value_digest: repair.valueDigest,
|
|
253
|
+
written_at: Date.now()
|
|
254
|
+
};
|
|
255
|
+
await db.insertInto('kubun_connector_observation_repair').values({
|
|
256
|
+
document_id: repair.documentID,
|
|
257
|
+
...values
|
|
258
|
+
}).onConflict((oc)=>oc.column('document_id').doUpdateSet(values)).execute();
|
|
259
|
+
},
|
|
260
|
+
async clearObservationRepair (documentID) {
|
|
261
|
+
await db.deleteFrom('kubun_connector_observation_repair').where('document_id', '=', documentID).execute();
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
export async function connectorObservationRecordAdapter(provider, methods) {
|
|
266
|
+
const connector = await provider.getStore('connector');
|
|
267
|
+
const records = {
|
|
268
|
+
isTransaction: provider.isTransactionBound,
|
|
269
|
+
async transaction (fn) {
|
|
270
|
+
return await provider.withTransaction(async (tx)=>await fn(await connectorObservationRecordAdapter(tx, methods)));
|
|
271
|
+
},
|
|
272
|
+
async get (key) {
|
|
273
|
+
requireType(key.type);
|
|
274
|
+
return await connector.getObservationRecordDecision(key, methods);
|
|
275
|
+
},
|
|
276
|
+
async getMany (keys) {
|
|
277
|
+
for (const key of keys)requireType(key.type);
|
|
278
|
+
const decisions = new Map();
|
|
279
|
+
for (const decision of (await connector.getObservationRecordDecisions(keys, methods))){
|
|
280
|
+
decisions.set(recordKeyText(decision.key), decision);
|
|
281
|
+
}
|
|
282
|
+
return decisions;
|
|
283
|
+
},
|
|
284
|
+
async list (types) {
|
|
285
|
+
for (const type of types)requireType(type);
|
|
286
|
+
return await connector.listObservationRecordDecisions(types, methods);
|
|
287
|
+
},
|
|
288
|
+
async putCopy (record) {
|
|
289
|
+
requireType(record.type);
|
|
290
|
+
await connector.putObservationRecordCopy(record, methods);
|
|
291
|
+
},
|
|
292
|
+
async pruneCopy (key, signerDID) {
|
|
293
|
+
requireType(key.type);
|
|
294
|
+
await connector.pruneObservationRecordCopy(key, signerDID, methods);
|
|
295
|
+
},
|
|
296
|
+
async lock (keys) {
|
|
297
|
+
for (const key of keys)requireType(key.type);
|
|
298
|
+
await connector.lockObservationRecordKeys(keys.map((key)=>JSON.stringify([
|
|
299
|
+
key.type,
|
|
300
|
+
key.key
|
|
301
|
+
])));
|
|
302
|
+
},
|
|
303
|
+
async getObservationDocumentOwner (documentID) {
|
|
304
|
+
const graph = await provider.getStore('graph');
|
|
305
|
+
return (await graph.getDocument(documentID))?.owner ?? null;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
providers.set(records, provider);
|
|
309
|
+
return records;
|
|
310
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ConnectorStoreAPI } from './api-types.js';
|
|
2
|
+
import type { ConnectorStoreContext } from './store-context.js';
|
|
3
|
+
export type OperationsMethods = Pick<ConnectorStoreAPI, 'claimOperation' | 'acquireLease' | 'renewLease' | 'releaseLease' | 'getOperation' | 'listTrackedOperations' | 'hasNewerOperationOnDocument' | 'markSendStarted' | 'deferOperation' | 'recordOperation'>;
|
|
4
|
+
export declare function createOperationsMethods(context: ConnectorStoreContext): OperationsMethods;
|