@kubun/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/index.d.ts +6 -1
- package/lib/index.js +5 -0
- package/lib/observation-capabilities.d.ts +28 -0
- package/lib/observation-capabilities.js +115 -0
- package/lib/observation.d.ts +18 -0
- package/lib/observation.js +121 -0
- package/lib/provider-utils.d.ts +22 -0
- package/lib/provider-utils.js +62 -0
- package/lib/sweep.d.ts +15 -0
- package/lib/sweep.js +22 -0
- package/lib/trim.d.ts +59 -0
- package/lib/trim.js +64 -0
- package/lib/types.d.ts +55 -1
- package/package.json +10 -5
package/lib/index.d.ts
CHANGED
|
@@ -3,4 +3,9 @@ export { clusterModelURNs, resolveModelID } from './cluster-utils.js';
|
|
|
3
3
|
export { ConnectorError, type ConnectorErrorKind, type ConnectorErrorOptions, classifyHTTPStatus, connectorHTTPError, markWriteSent, } from './errors.js';
|
|
4
4
|
export { connectorClusters, connectorModelIDs } from './models/cluster.js';
|
|
5
5
|
export { externalEntityDefinition } from './models/external-entity.js';
|
|
6
|
-
export
|
|
6
|
+
export { type ObservationDelegatedWrite, type ObservationDeps, observationRecordType, parseObservationPayload, providerValueDigest, } from './observation.js';
|
|
7
|
+
export { MAX_OBSERVATION_CAPABILITIES, MAX_OBSERVATION_CAPABILITY_BYTES, MAX_OBSERVATION_CAPABILITY_DEPTH, MAX_OBSERVATION_CAPABILITY_TOKEN_BYTES, MAX_OBSERVATION_CAPABILITY_VISITS, MAX_OBSERVATION_JWT_BYTES, MAX_OBSERVATION_PREFETCH_DIDS, ObservationCapabilityError, type ObservationCapabilityValidationParams, signObservationOp, validateObservationCapabilities, } from './observation-capabilities.js';
|
|
8
|
+
export { addBoundaryDuration, type FetchOrAbsentOptions, fetchOrAbsent, isNotFoundError, parseMaxAgeDays, parseSyncBoundaryDuration, type TargetedRead, } from './provider-utils.js';
|
|
9
|
+
export { classifyVerification, isSweepCandidate, type RelistReceipt, type SweepCandidateReceipt, type VerificationOutcome, type VerificationResult, } from './sweep.js';
|
|
10
|
+
export { carryField, isFieldTrimmed, refuseCutLists, type TrimmedArray, type TrimmedField, TrimmedFieldWriteError, type TrimmedString, } from './trim.js';
|
|
11
|
+
export type { ActionDefinition, ActionHandler, ActionHandlerFactory, ActionResult, ActionTargetResolver, ActionTargetStorage, ActionWriteContext, ActionWriteResult, ClientDataProvider, ClientProviderFactory, ConnectorAuth, ConnectorDefinition, ConnectorSyncEventPayload, CreatedSourceIDPredictor, DataProvider, DeadLetter, DeviceConnectorAuth, DevicePermission, EntityBatch, EntityRecord, FetchChangesParams, FetchedAbsentEntity, FetchedEntity, FetchedPresentEntity, FetchParams, IntentCanonicalizer, IntentComparison, JSONValue, LocalAnnotationMerger, OAuthConnectorAuth, OAuthState, Observation, PendingOp, ReconcileResult, Recovery, ResolvedActionTarget, ServerDataProvider, ServerProviderFactory, SyncBoundary, SyncCompleteEvent, SyncErrorEvent, SyncEvent, SyncFailureSample, SyncProgressCASCurrent, SyncProgressCASResult, SyncProgressEvent, SyncProgressRecompute, SyncState, SyncStateEntry, SyncStateStore, SyncStatus, SyncSweep, UserSyncPreferences, WebhookParams, WebhookRegistration, WriteAttachment, } from './types.js';
|
package/lib/index.js
CHANGED
|
@@ -3,3 +3,8 @@ export { clusterModelURNs, resolveModelID } from './cluster-utils.js';
|
|
|
3
3
|
export { ConnectorError, classifyHTTPStatus, connectorHTTPError, markWriteSent } from './errors.js';
|
|
4
4
|
export { connectorClusters, connectorModelIDs } from './models/cluster.js';
|
|
5
5
|
export { externalEntityDefinition } from './models/external-entity.js';
|
|
6
|
+
export { observationRecordType, parseObservationPayload, providerValueDigest } from './observation.js';
|
|
7
|
+
export { MAX_OBSERVATION_CAPABILITIES, MAX_OBSERVATION_CAPABILITY_BYTES, MAX_OBSERVATION_CAPABILITY_DEPTH, MAX_OBSERVATION_CAPABILITY_TOKEN_BYTES, MAX_OBSERVATION_CAPABILITY_VISITS, MAX_OBSERVATION_JWT_BYTES, MAX_OBSERVATION_PREFETCH_DIDS, ObservationCapabilityError, signObservationOp, validateObservationCapabilities } from './observation-capabilities.js';
|
|
8
|
+
export { addBoundaryDuration, fetchOrAbsent, isNotFoundError, parseMaxAgeDays, parseSyncBoundaryDuration } from './provider-utils.js';
|
|
9
|
+
export { classifyVerification, isSweepCandidate } from './sweep.js';
|
|
10
|
+
export { carryField, isFieldTrimmed, refuseCutLists, TrimmedFieldWriteError } from './trim.js';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SigningIdentity } from '@kokuin/token';
|
|
2
|
+
import { type RecordClaim, RecordContentRefusalError } from '@kubun/records';
|
|
3
|
+
export declare const MAX_OBSERVATION_CAPABILITIES = 16;
|
|
4
|
+
export declare const MAX_OBSERVATION_CAPABILITY_BYTES: number;
|
|
5
|
+
export declare const MAX_OBSERVATION_CAPABILITY_DEPTH = 5;
|
|
6
|
+
export declare const MAX_OBSERVATION_CAPABILITY_VISITS = 64;
|
|
7
|
+
export declare const MAX_OBSERVATION_PREFETCH_DIDS = 64;
|
|
8
|
+
export declare const MAX_OBSERVATION_CAPABILITY_TOKEN_BYTES: number;
|
|
9
|
+
export declare const MAX_OBSERVATION_JWT_BYTES: number;
|
|
10
|
+
export declare class ObservationCapabilityError extends RecordContentRefusalError {
|
|
11
|
+
constructor(message: string, options?: ErrorOptions);
|
|
12
|
+
}
|
|
13
|
+
type ObservationCapabilities = {
|
|
14
|
+
cap: unknown;
|
|
15
|
+
issuer: string;
|
|
16
|
+
owner: string;
|
|
17
|
+
};
|
|
18
|
+
export type ObservationCapabilityValidationParams = ObservationCapabilities | {
|
|
19
|
+
opJWT: string;
|
|
20
|
+
};
|
|
21
|
+
/** Bound all traversal work before any signer or capability is resolved or verified. */
|
|
22
|
+
export declare function validateObservationCapabilities(params: ObservationCapabilityValidationParams): Array<string>;
|
|
23
|
+
type SignedObservationOp = {
|
|
24
|
+
opJWT: string;
|
|
25
|
+
opHash: string;
|
|
26
|
+
};
|
|
27
|
+
export declare function signObservationOp(identity: SigningIdentity, claim: RecordClaim): Promise<SignedObservationOp>;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { RecordContentRefusalError, signRecordOp } from '@kubun/records';
|
|
2
|
+
import { fromB64U, fromUTF, toUTF } from '@sozai/codec';
|
|
3
|
+
export const MAX_OBSERVATION_CAPABILITIES = 16;
|
|
4
|
+
export const MAX_OBSERVATION_CAPABILITY_BYTES = 64 * 1024;
|
|
5
|
+
export const MAX_OBSERVATION_CAPABILITY_DEPTH = 5;
|
|
6
|
+
export const MAX_OBSERVATION_CAPABILITY_VISITS = 64;
|
|
7
|
+
export const MAX_OBSERVATION_PREFETCH_DIDS = 64;
|
|
8
|
+
// Cheap per-string guard before any decode.
|
|
9
|
+
export const MAX_OBSERVATION_CAPABILITY_TOKEN_BYTES = 64 * 1024;
|
|
10
|
+
export const MAX_OBSERVATION_JWT_BYTES = 256 * 1024;
|
|
11
|
+
export class ObservationCapabilityError extends RecordContentRefusalError {
|
|
12
|
+
constructor(message, options){
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'ObservationCapabilityError';
|
|
15
|
+
this.cause = options?.cause;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function isObject(value) {
|
|
19
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
function byteLength(value, limit) {
|
|
22
|
+
if (value.length > limit) throw new ObservationCapabilityError('Observation JWT byte limit exceeded');
|
|
23
|
+
const bytes = fromUTF(value).byteLength;
|
|
24
|
+
if (bytes > limit) throw new ObservationCapabilityError('Observation JWT byte limit exceeded');
|
|
25
|
+
return bytes;
|
|
26
|
+
}
|
|
27
|
+
function tokenPayload(token) {
|
|
28
|
+
const parts = token.split('.');
|
|
29
|
+
if (parts.length !== 3 || parts[1] === undefined) throw new ObservationCapabilityError('Observation capability JWT is malformed');
|
|
30
|
+
let value;
|
|
31
|
+
try {
|
|
32
|
+
value = JSON.parse(toUTF(fromB64U(parts[1])));
|
|
33
|
+
} catch (cause) {
|
|
34
|
+
throw new ObservationCapabilityError('Observation capability JWT is malformed', {
|
|
35
|
+
cause
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (!isObject(value)) throw new ObservationCapabilityError('Observation capability payload is malformed');
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function observationCapabilities(opJWT) {
|
|
42
|
+
byteLength(opJWT, MAX_OBSERVATION_JWT_BYTES);
|
|
43
|
+
const payload = tokenPayload(opJWT);
|
|
44
|
+
if (typeof payload.iss !== 'string' || typeof payload.claim !== 'string') throw new ObservationCapabilityError('Observation claim envelope is malformed');
|
|
45
|
+
let claim;
|
|
46
|
+
try {
|
|
47
|
+
claim = JSON.parse(toUTF(fromB64U(payload.claim)));
|
|
48
|
+
} catch (cause) {
|
|
49
|
+
throw new ObservationCapabilityError('Observation claim is malformed', {
|
|
50
|
+
cause
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (!isObject(claim) || typeof claim.owner !== 'string' || !isObject(claim.meta)) throw new ObservationCapabilityError('Observation claim is malformed');
|
|
54
|
+
return {
|
|
55
|
+
cap: claim.meta.cap,
|
|
56
|
+
issuer: payload.iss,
|
|
57
|
+
owner: claim.owner
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function tokensOf(cap) {
|
|
61
|
+
if (cap == null) return [];
|
|
62
|
+
if (typeof cap === 'string') return [
|
|
63
|
+
cap
|
|
64
|
+
];
|
|
65
|
+
if (Array.isArray(cap)) return cap;
|
|
66
|
+
throw new ObservationCapabilityError('Observation capabilities are malformed');
|
|
67
|
+
}
|
|
68
|
+
/** Bound all traversal work before any signer or capability is resolved or verified. */ export function validateObservationCapabilities(params) {
|
|
69
|
+
const { cap, issuer, owner } = 'opJWT' in params ? observationCapabilities(params.opJWT) : params;
|
|
70
|
+
const roots = tokensOf(cap);
|
|
71
|
+
if (roots.length > MAX_OBSERVATION_CAPABILITIES) throw new ObservationCapabilityError('Observation capability count limit exceeded');
|
|
72
|
+
const dids = new Set();
|
|
73
|
+
const addDID = (did)=>{
|
|
74
|
+
dids.add(did);
|
|
75
|
+
if (dids.size > MAX_OBSERVATION_PREFETCH_DIDS) throw new ObservationCapabilityError('Observation prefetch DID limit exceeded');
|
|
76
|
+
};
|
|
77
|
+
addDID(owner);
|
|
78
|
+
addDID(issuer);
|
|
79
|
+
let visits = 0;
|
|
80
|
+
let bytes = 0;
|
|
81
|
+
const visit = (token, depth)=>{
|
|
82
|
+
if (depth > MAX_OBSERVATION_CAPABILITY_DEPTH) throw new ObservationCapabilityError('Observation capability depth limit exceeded');
|
|
83
|
+
if (++visits > MAX_OBSERVATION_CAPABILITY_VISITS) throw new ObservationCapabilityError('Observation capability visit limit exceeded');
|
|
84
|
+
if (typeof token !== 'string' || token.length === 0) throw new ObservationCapabilityError('Observation capability token is malformed');
|
|
85
|
+
bytes += byteLength(token, MAX_OBSERVATION_CAPABILITY_TOKEN_BYTES);
|
|
86
|
+
if (bytes > MAX_OBSERVATION_CAPABILITY_BYTES) throw new ObservationCapabilityError('Observation aggregate capability byte limit exceeded');
|
|
87
|
+
const payload = tokenPayload(token);
|
|
88
|
+
for (const field of [
|
|
89
|
+
'iss',
|
|
90
|
+
'sub',
|
|
91
|
+
'aud'
|
|
92
|
+
]){
|
|
93
|
+
const did = payload[field];
|
|
94
|
+
if (typeof did === 'string') addDID(did);
|
|
95
|
+
}
|
|
96
|
+
// Embedded and repeated tokens spend the budget on every visit.
|
|
97
|
+
for (const nested of tokensOf(payload.cap))visit(nested, depth + 1);
|
|
98
|
+
};
|
|
99
|
+
for (const token of roots)visit(token, 0);
|
|
100
|
+
return [
|
|
101
|
+
...dids
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
export async function signObservationOp(identity, claim) {
|
|
105
|
+
validateObservationCapabilities({
|
|
106
|
+
cap: claim.meta.cap,
|
|
107
|
+
issuer: identity.id,
|
|
108
|
+
owner: claim.owner
|
|
109
|
+
});
|
|
110
|
+
const signed = await signRecordOp(identity, claim);
|
|
111
|
+
validateObservationCapabilities({
|
|
112
|
+
opJWT: signed.opJWT
|
|
113
|
+
});
|
|
114
|
+
return signed;
|
|
115
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type RecordStoreAdapter, type RecordTypeDefinition } from '@kubun/records';
|
|
2
|
+
import type { Observation } from './types.js';
|
|
3
|
+
export type ObservationDeps = {
|
|
4
|
+
resolveController: (did: string) => Promise<string | null>;
|
|
5
|
+
verifyDelegatedWrite?: (params: ObservationDelegatedWrite) => Promise<boolean>;
|
|
6
|
+
now?: () => number;
|
|
7
|
+
};
|
|
8
|
+
export type ObservationDelegatedWrite = {
|
|
9
|
+
documentID: string;
|
|
10
|
+
owner: string;
|
|
11
|
+
issuer: string;
|
|
12
|
+
tokens: Array<string>;
|
|
13
|
+
atTime: number;
|
|
14
|
+
store: RecordStoreAdapter;
|
|
15
|
+
};
|
|
16
|
+
export declare function providerValueDigest(value: unknown, fields: ReadonlyArray<string>): string;
|
|
17
|
+
export declare function parseObservationPayload(payload: Uint8Array | null): Observation | null;
|
|
18
|
+
export declare function observationRecordType(deps: ObservationDeps): RecordTypeDefinition;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { HLC } from '@kubun/hlc';
|
|
2
|
+
import { digestJSON } from '@kubun/id';
|
|
3
|
+
import { recordPayloadMatches } from '@kubun/records';
|
|
4
|
+
import { toB64U, toUTF } from '@sozai/codec';
|
|
5
|
+
import { ObservationCapabilityError, validateObservationCapabilities } from './observation-capabilities.js';
|
|
6
|
+
function isObject(value) {
|
|
7
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function withoutObjectNulls(value) {
|
|
10
|
+
if (Array.isArray(value)) return value.map((item)=>withoutObjectNulls(item));
|
|
11
|
+
if (!isObject(value)) return value;
|
|
12
|
+
const result = {};
|
|
13
|
+
for (const [key, item] of Object.entries(value)){
|
|
14
|
+
if (item !== null && item !== undefined) result[key] = withoutObjectNulls(item);
|
|
15
|
+
}
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
export function providerValueDigest(value, fields) {
|
|
19
|
+
const source = isObject(value) ? value : {};
|
|
20
|
+
const projection = {};
|
|
21
|
+
for (const field of fields){
|
|
22
|
+
const item = source[field];
|
|
23
|
+
if (item !== null && item !== undefined) projection[field] = withoutObjectNulls(item);
|
|
24
|
+
}
|
|
25
|
+
return toB64U(digestJSON(projection));
|
|
26
|
+
}
|
|
27
|
+
export function parseObservationPayload(payload) {
|
|
28
|
+
if (payload === null) return null;
|
|
29
|
+
let value;
|
|
30
|
+
try {
|
|
31
|
+
value = JSON.parse(toUTF(payload));
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (!isObject(value)) return null;
|
|
36
|
+
const { documentID, fetchHLC, decisionHLC, present, valueDigest } = value;
|
|
37
|
+
if (Object.keys(value).length !== 5 || typeof documentID !== 'string' || typeof fetchHLC !== 'string' || typeof decisionHLC !== 'string' || typeof present !== 'boolean' || (present ? typeof valueDigest !== 'string' : valueDigest !== null)) return null;
|
|
38
|
+
const fetch = HLC.tryParse(fetchHLC);
|
|
39
|
+
const decision = HLC.tryParse(decisionHLC);
|
|
40
|
+
if (fetch === null || decision === null || HLC.serialize(fetch) !== fetchHLC || HLC.serialize(decision) !== decisionHLC || decisionHLC !== fetchHLC) return null;
|
|
41
|
+
return {
|
|
42
|
+
documentID,
|
|
43
|
+
fetchHLC,
|
|
44
|
+
decisionHLC,
|
|
45
|
+
present,
|
|
46
|
+
valueDigest: valueDigest
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function observationRecordType(deps) {
|
|
50
|
+
return {
|
|
51
|
+
type: 'connector.observation',
|
|
52
|
+
policy: {
|
|
53
|
+
kind: 'monotonic',
|
|
54
|
+
versionOrder: 'hlc',
|
|
55
|
+
version: (claim)=>{
|
|
56
|
+
const version = claim.meta.version;
|
|
57
|
+
const parsed = typeof version === 'string' ? HLC.tryParse(version) : null;
|
|
58
|
+
return parsed === null ? [
|
|
59
|
+
Number.NaN
|
|
60
|
+
] : [
|
|
61
|
+
parsed.wallTime,
|
|
62
|
+
parsed.counter
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
dependsOn: [],
|
|
67
|
+
dependencies: ()=>[],
|
|
68
|
+
lockKeys: async (record)=>[
|
|
69
|
+
{
|
|
70
|
+
type: 'connector.observation',
|
|
71
|
+
key: JSON.stringify([
|
|
72
|
+
record.owner,
|
|
73
|
+
record.id
|
|
74
|
+
])
|
|
75
|
+
}
|
|
76
|
+
],
|
|
77
|
+
precheck: async (op, record)=>{
|
|
78
|
+
try {
|
|
79
|
+
validateObservationCapabilities({
|
|
80
|
+
opJWT: record.opJWT
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error instanceof ObservationCapabilityError) return false;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
if (op.claim.deleted) return record.payload === null;
|
|
87
|
+
const observation = parseObservationPayload(record.payload);
|
|
88
|
+
return observation !== null && recordPayloadMatches(record, op.claim) && observation.documentID === op.claim.id && observation.decisionHLC === op.claim.meta.version && op.claim.hlc === op.claim.meta.version;
|
|
89
|
+
},
|
|
90
|
+
authorize: async (op, ctx)=>{
|
|
91
|
+
try {
|
|
92
|
+
validateObservationCapabilities({
|
|
93
|
+
cap: op.claim.meta.cap,
|
|
94
|
+
issuer: op.issuer,
|
|
95
|
+
owner: op.claim.owner
|
|
96
|
+
});
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error instanceof ObservationCapabilityError) return false;
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
if (op.claim.hlc !== op.claim.meta.version) return false;
|
|
102
|
+
const adapter = ctx.store.adapterForType?.('connector.observation') ?? ctx.store;
|
|
103
|
+
if (adapter.getObservationDocumentOwner === undefined) throw new Error('Observation adapter cannot resolve document owner');
|
|
104
|
+
const documentOwner = await adapter.getObservationDocumentOwner(op.claim.id);
|
|
105
|
+
if (documentOwner === null || documentOwner !== op.claim.owner) return false;
|
|
106
|
+
if (op.issuer === op.claim.owner) return true;
|
|
107
|
+
if (await deps.resolveController(op.issuer) === op.claim.owner) return true;
|
|
108
|
+
const cap = op.claim.meta.cap;
|
|
109
|
+
if (!Array.isArray(cap) || cap.length === 0 || !cap.every((token)=>typeof token === 'string') || deps.verifyDelegatedWrite === undefined) return false;
|
|
110
|
+
if (HLC.tryParse(op.claim.hlc) === null) return false;
|
|
111
|
+
return await deps.verifyDelegatedWrite({
|
|
112
|
+
documentID: op.claim.id,
|
|
113
|
+
owner: op.claim.owner,
|
|
114
|
+
issuer: op.issuer,
|
|
115
|
+
tokens: cap,
|
|
116
|
+
atTime: Math.floor((deps.now ?? Date.now)() / 1000),
|
|
117
|
+
store: adapter
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { EntityRecord, ReconcileResult, SyncBoundary } from './types.js';
|
|
2
|
+
export type TargetedRead = {
|
|
3
|
+
record: EntityRecord;
|
|
4
|
+
providerETag?: string;
|
|
5
|
+
};
|
|
6
|
+
export type FetchOrAbsentOptions = {
|
|
7
|
+
sourceID: string;
|
|
8
|
+
sourceFetchHLC: string;
|
|
9
|
+
read: () => Promise<TargetedRead>;
|
|
10
|
+
};
|
|
11
|
+
export declare function isNotFoundError(err: unknown): boolean;
|
|
12
|
+
export declare function fetchOrAbsent(options: FetchOrAbsentOptions): Promise<ReconcileResult>;
|
|
13
|
+
export declare function parseMaxAgeDays(boundary: SyncBoundary | undefined): number | null;
|
|
14
|
+
export declare function parseSyncBoundaryDuration(boundary: SyncBoundary | undefined): number | null;
|
|
15
|
+
/**
|
|
16
|
+
* `from` moved forward by a `P<n>D` or `P<n>Y` duration, in UTC. Years are
|
|
17
|
+
* calendar years (a year spanning a leap day is 366 days); a leap day plus a
|
|
18
|
+
* whole number of years lands on the last day of February when the target year
|
|
19
|
+
* has none. Case-insensitive, like `parseMaxAgeDays`. Returns null for any
|
|
20
|
+
* other or non-positive duration, and for one past the representable dates.
|
|
21
|
+
*/
|
|
22
|
+
export declare function addBoundaryDuration(from: Date, duration: string): Date | null;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { parseCatalogDuration } from '@kubun/protocol';
|
|
2
|
+
export function isNotFoundError(err) {
|
|
3
|
+
return err?.httpStatus === 404;
|
|
4
|
+
}
|
|
5
|
+
export async function fetchOrAbsent(options) {
|
|
6
|
+
try {
|
|
7
|
+
const { record, providerETag } = await options.read();
|
|
8
|
+
return {
|
|
9
|
+
present: true,
|
|
10
|
+
record,
|
|
11
|
+
sourceFetchHLC: options.sourceFetchHLC,
|
|
12
|
+
...providerETag != null ? {
|
|
13
|
+
providerETag
|
|
14
|
+
} : {}
|
|
15
|
+
};
|
|
16
|
+
} catch (err) {
|
|
17
|
+
if (isNotFoundError(err)) {
|
|
18
|
+
return {
|
|
19
|
+
present: false,
|
|
20
|
+
sourceID: options.sourceID,
|
|
21
|
+
sourceFetchHLC: options.sourceFetchHLC
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function parseMaxAgeDays(boundary) {
|
|
28
|
+
if (boundary?.maxAge == null) return null;
|
|
29
|
+
try {
|
|
30
|
+
return parseCatalogDuration(boundary.maxAge.toUpperCase()) / 86_400_000;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function parseSyncBoundaryDuration(boundary) {
|
|
36
|
+
return boundary?.maxAge == null ? null : parseCatalogDuration(boundary.maxAge);
|
|
37
|
+
}
|
|
38
|
+
const BOUNDARY_DURATION = /^P(\d+)([DY])$/i;
|
|
39
|
+
/**
|
|
40
|
+
* `from` moved forward by a `P<n>D` or `P<n>Y` duration, in UTC. Years are
|
|
41
|
+
* calendar years (a year spanning a leap day is 366 days); a leap day plus a
|
|
42
|
+
* whole number of years lands on the last day of February when the target year
|
|
43
|
+
* has none. Case-insensitive, like `parseMaxAgeDays`. Returns null for any
|
|
44
|
+
* other or non-positive duration, and for one past the representable dates.
|
|
45
|
+
*/ export function addBoundaryDuration(from, duration) {
|
|
46
|
+
const match = BOUNDARY_DURATION.exec(duration);
|
|
47
|
+
if (match?.[1] == null) return null;
|
|
48
|
+
const amount = Number.parseInt(match[1], 10);
|
|
49
|
+
if (amount <= 0) return null;
|
|
50
|
+
const result = new Date(from.getTime());
|
|
51
|
+
if (match[2]?.toUpperCase() === 'D') {
|
|
52
|
+
result.setUTCDate(result.getUTCDate() + amount);
|
|
53
|
+
} else {
|
|
54
|
+
const month = result.getUTCMonth();
|
|
55
|
+
result.setUTCFullYear(result.getUTCFullYear() + amount);
|
|
56
|
+
if (result.getUTCMonth() !== month) {
|
|
57
|
+
// Day 0 of the overflowed month is the last day of the intended one.
|
|
58
|
+
result.setUTCDate(0);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return Number.isNaN(result.getTime()) ? null : result;
|
|
62
|
+
}
|
package/lib/sweep.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FetchedEntity, SyncSweep } from './types.js';
|
|
2
|
+
export type RelistReceipt = SyncSweep;
|
|
3
|
+
export type VerificationOutcome = 'present' | 'absent' | 'cancelled' | 'uncertain';
|
|
4
|
+
export type VerificationResult = FetchedEntity | {
|
|
5
|
+
outcome: 'cancelled' | 'uncertain';
|
|
6
|
+
sourceFetchHLC: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function classifyVerification(result: VerificationResult | unknown): VerificationOutcome;
|
|
9
|
+
export type SweepCandidateReceipt = Pick<RelistReceipt, 'complete' | 'predicate'>;
|
|
10
|
+
/**
|
|
11
|
+
* Whether a stored document is a deletion candidate of a completed relist:
|
|
12
|
+
* not listed (`keep`) and inside the receipt's predicate. The stored start
|
|
13
|
+
* date is a conservative subset of Calendar's list window.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isSweepCandidate(receipt: SweepCandidateReceipt, keep: ReadonlySet<string>, data: Record<string, unknown>): boolean;
|
package/lib/sweep.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function classifyVerification(result) {
|
|
2
|
+
if (result == null || typeof result !== 'object') return 'uncertain';
|
|
3
|
+
if ('outcome' in result) {
|
|
4
|
+
return result.outcome === 'cancelled' ? 'cancelled' : 'uncertain';
|
|
5
|
+
}
|
|
6
|
+
if ('present' in result) return result.present === true ? 'present' : result.present === false ? 'absent' : 'uncertain';
|
|
7
|
+
return 'uncertain';
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Whether a stored document is a deletion candidate of a completed relist:
|
|
11
|
+
* not listed (`keep`) and inside the receipt's predicate. The stored start
|
|
12
|
+
* date is a conservative subset of Calendar's list window.
|
|
13
|
+
*/ export function isSweepCandidate(receipt, keep, data) {
|
|
14
|
+
if (!receipt.complete || receipt.predicate == null) return false;
|
|
15
|
+
if (typeof data.sourceID !== 'string' || keep.has(data.sourceID)) return false;
|
|
16
|
+
const timestamp = data[receipt.predicate.field];
|
|
17
|
+
if (typeof timestamp !== 'string') return false;
|
|
18
|
+
const value = Date.parse(timestamp);
|
|
19
|
+
const from = receipt.predicate.from == null ? Number.NEGATIVE_INFINITY : Date.parse(receipt.predicate.from);
|
|
20
|
+
const to = receipt.predicate.to == null ? Number.POSITIVE_INFINITY : Date.parse(receipt.predicate.to);
|
|
21
|
+
return Number.isFinite(value) && value >= from && value < to;
|
|
22
|
+
}
|
package/lib/trim.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ConnectorError } from './errors.js';
|
|
2
|
+
import type { ActionWriteContext } from './types.js';
|
|
3
|
+
/** A source string cut to its model's `maxLength`, in code points. */
|
|
4
|
+
export type TrimmedString = {
|
|
5
|
+
/** JSON Pointer (RFC 6901) to the value in the written document. */
|
|
6
|
+
path: string;
|
|
7
|
+
kind: 'string';
|
|
8
|
+
/** The source value's length in code points, before the cut. */
|
|
9
|
+
originalLength: number;
|
|
10
|
+
};
|
|
11
|
+
/** A source array cut to its model's `maxItems`. */
|
|
12
|
+
export type TrimmedArray = {
|
|
13
|
+
/** JSON Pointer (RFC 6901) to the value in the written document. */
|
|
14
|
+
path: string;
|
|
15
|
+
kind: 'array';
|
|
16
|
+
/** The source array's item count, before the cut. */
|
|
17
|
+
originalCount: number;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* A source value a connector fitted to its model's caps before writing the
|
|
21
|
+
* document: the stored value is a prefix of the provider's, never the whole.
|
|
22
|
+
*/
|
|
23
|
+
export type TrimmedField = TrimmedString | TrimmedArray;
|
|
24
|
+
/**
|
|
25
|
+
* Whether the stored value of top-level `field` is not the provider's own:
|
|
26
|
+
* the field itself, or any value inside it, was trimmed.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isFieldTrimmed(trimmedFields: ReadonlyArray<TrimmedField> | undefined, field: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Refuses a write whose caller-set fields land on a list that was cut to the
|
|
31
|
+
* model's cap, at the field or anywhere below it. The caller built that value
|
|
32
|
+
* from the stored prefix, never the provider's full list, so sending it would
|
|
33
|
+
* delete the items the cut dropped. A caller-set string on a cut string path
|
|
34
|
+
* is still written: the caller set that whole value.
|
|
35
|
+
*/
|
|
36
|
+
export declare function refuseCutLists(params: Record<string, unknown>, context: ActionWriteContext | undefined): void;
|
|
37
|
+
/**
|
|
38
|
+
* A write would send a field whose stored value was trimmed, so it would cut
|
|
39
|
+
* the provider's full value down to the stored prefix. Classified as a
|
|
40
|
+
* definite rejection: nothing was sent, and the same write is refused again.
|
|
41
|
+
*/
|
|
42
|
+
export declare class TrimmedFieldWriteError extends ConnectorError {
|
|
43
|
+
#private;
|
|
44
|
+
/**
|
|
45
|
+
* `list` names a write of a cut list, which the caller cannot make whole:
|
|
46
|
+
* it never saw the items the cut dropped.
|
|
47
|
+
*/
|
|
48
|
+
constructor(field: string, options?: {
|
|
49
|
+
list?: boolean;
|
|
50
|
+
});
|
|
51
|
+
get field(): string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The target document's value of `field`, for a write that must resend it
|
|
55
|
+
* alongside the fields it changes (a provider that takes two fields only as a
|
|
56
|
+
* pair, say). Throws {@link TrimmedFieldWriteError} when the stored value was
|
|
57
|
+
* trimmed, rather than write the prefix over the provider's full value.
|
|
58
|
+
*/
|
|
59
|
+
export declare function carryField(existing: Record<string, unknown> | undefined, field: string, context: ActionWriteContext | undefined): unknown;
|
package/lib/trim.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { ConnectorError } from './errors.js';
|
|
2
|
+
function pointerSegment(field) {
|
|
3
|
+
return field.replaceAll('~', '~0').replaceAll('/', '~1');
|
|
4
|
+
}
|
|
5
|
+
function isAtOrBelow(path, field) {
|
|
6
|
+
const pointer = `/${pointerSegment(field)}`;
|
|
7
|
+
return path === pointer || path.startsWith(`${pointer}/`);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Whether the stored value of top-level `field` is not the provider's own:
|
|
11
|
+
* the field itself, or any value inside it, was trimmed.
|
|
12
|
+
*/ export function isFieldTrimmed(trimmedFields, field) {
|
|
13
|
+
if (trimmedFields == null) return false;
|
|
14
|
+
return trimmedFields.some((trimmed)=>isAtOrBelow(trimmed.path, field));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Refuses a write whose caller-set fields land on a list that was cut to the
|
|
18
|
+
* model's cap, at the field or anywhere below it. The caller built that value
|
|
19
|
+
* from the stored prefix, never the provider's full list, so sending it would
|
|
20
|
+
* delete the items the cut dropped. A caller-set string on a cut string path
|
|
21
|
+
* is still written: the caller set that whole value.
|
|
22
|
+
*/ export function refuseCutLists(params, context) {
|
|
23
|
+
const cutLists = context?.trimmedFields?.filter((trimmed)=>trimmed.kind === 'array') ?? [];
|
|
24
|
+
if (cutLists.length === 0) return;
|
|
25
|
+
for (const field of Object.keys(params)){
|
|
26
|
+
if (cutLists.some((trimmed)=>isAtOrBelow(trimmed.path, field))) {
|
|
27
|
+
throw new TrimmedFieldWriteError(field, {
|
|
28
|
+
list: true
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A write would send a field whose stored value was trimmed, so it would cut
|
|
35
|
+
* the provider's full value down to the stored prefix. Classified as a
|
|
36
|
+
* definite rejection: nothing was sent, and the same write is refused again.
|
|
37
|
+
*/ export class TrimmedFieldWriteError extends ConnectorError {
|
|
38
|
+
#field;
|
|
39
|
+
/**
|
|
40
|
+
* `list` names a write of a cut list, which the caller cannot make whole:
|
|
41
|
+
* it never saw the items the cut dropped.
|
|
42
|
+
*/ constructor(field, options = {}){
|
|
43
|
+
super(options.list === true ? `"${field}" holds a cut copy of the provider's list; writing it would drop the items the cut left out` : `"${field}" holds a trimmed copy of the provider's value; set it explicitly to write it`, {
|
|
44
|
+
code: 'TRIMMED_FIELD',
|
|
45
|
+
kind: 'rejected'
|
|
46
|
+
});
|
|
47
|
+
this.name = 'TrimmedFieldWriteError';
|
|
48
|
+
this.#field = field;
|
|
49
|
+
}
|
|
50
|
+
get field() {
|
|
51
|
+
return this.#field;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The target document's value of `field`, for a write that must resend it
|
|
56
|
+
* alongside the fields it changes (a provider that takes two fields only as a
|
|
57
|
+
* pair, say). Throws {@link TrimmedFieldWriteError} when the stored value was
|
|
58
|
+
* trimmed, rather than write the prefix over the provider's full value.
|
|
59
|
+
*/ export function carryField(existing, field, context) {
|
|
60
|
+
if (isFieldTrimmed(context?.trimmedFields, field)) {
|
|
61
|
+
throw new TrimmedFieldWriteError(field);
|
|
62
|
+
}
|
|
63
|
+
return existing?.[field];
|
|
64
|
+
}
|
package/lib/types.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import type { Credential, ProviderAccount } from '@kubun/credential-types';
|
|
2
2
|
import type { ClustersRecord } from '@kubun/protocol';
|
|
3
3
|
import type { JSONSchema } from 'json-schema-typed';
|
|
4
|
+
import type { VerificationResult } from './sweep.js';
|
|
5
|
+
import type { TrimmedField } from './trim.js';
|
|
6
|
+
export type Observation = {
|
|
7
|
+
documentID: string;
|
|
8
|
+
fetchHLC: string;
|
|
9
|
+
decisionHLC: string;
|
|
10
|
+
present: boolean;
|
|
11
|
+
valueDigest: string | null;
|
|
12
|
+
};
|
|
4
13
|
export type OAuthState = {
|
|
5
14
|
provider: string;
|
|
6
15
|
redirectURL: string;
|
|
@@ -82,6 +91,7 @@ export type SyncBoundary = {
|
|
|
82
91
|
maxAge?: string;
|
|
83
92
|
since?: string;
|
|
84
93
|
maxEntities?: number;
|
|
94
|
+
maxAhead?: string;
|
|
85
95
|
};
|
|
86
96
|
export type UserSyncPreferences = {
|
|
87
97
|
connectorName: string;
|
|
@@ -108,10 +118,41 @@ export type FetchedPresentEntity = Extract<FetchedEntity, {
|
|
|
108
118
|
export type FetchedAbsentEntity = Extract<FetchedEntity, {
|
|
109
119
|
present: false;
|
|
110
120
|
}>;
|
|
121
|
+
/**
|
|
122
|
+
* A completed provider relist receipt for one source partition, emitted on the
|
|
123
|
+
* partition's final page only.
|
|
124
|
+
*
|
|
125
|
+
* - `relist`: `keep` lists every source ID the listing returned; documents in
|
|
126
|
+
* the partition whose stored `predicate.field` lies in `[from, to)` and are
|
|
127
|
+
* not kept become durable candidates, each deleted only after a by-ID
|
|
128
|
+
* verification reports it absent or cancelled.
|
|
129
|
+
* - `partition-removal`: the provider reported the whole partition gone (such
|
|
130
|
+
* as a removed calendar); `predicate` is null, `keep` is unused, and every
|
|
131
|
+
* document in it is removed with `sourceFetchHLC`, without per-document
|
|
132
|
+
* verification.
|
|
133
|
+
*/
|
|
134
|
+
export type SyncSweep = {
|
|
135
|
+
modelID: string;
|
|
136
|
+
scope: Record<string, string>;
|
|
137
|
+
keep: Array<string>;
|
|
138
|
+
sourceFetchHLC: string;
|
|
139
|
+
partition: string;
|
|
140
|
+
/** The listing's token or snapshot provenance; keys the sweep checkpoint. */
|
|
141
|
+
provenance: string;
|
|
142
|
+
complete: boolean;
|
|
143
|
+
/** The exact relist window as ISO bounds; documents outside it are untouched. */
|
|
144
|
+
predicate: {
|
|
145
|
+
field: string;
|
|
146
|
+
from: string | null;
|
|
147
|
+
to: string | null;
|
|
148
|
+
} | null;
|
|
149
|
+
kind: 'relist' | 'partition-removal';
|
|
150
|
+
};
|
|
111
151
|
export type EntityBatch = {
|
|
112
152
|
modelID: string;
|
|
113
153
|
entities: Array<FetchedPresentEntity>;
|
|
114
154
|
deleted?: Array<FetchedAbsentEntity>;
|
|
155
|
+
sweep?: SyncSweep;
|
|
115
156
|
checkpoint?: unknown;
|
|
116
157
|
hasMore: boolean;
|
|
117
158
|
};
|
|
@@ -146,6 +187,7 @@ export type DataProvider = {
|
|
|
146
187
|
fetchAll(params: FetchParams): AsyncIterable<EntityBatch>;
|
|
147
188
|
fetchChanges(params: FetchChangesParams): AsyncIterable<EntityBatch>;
|
|
148
189
|
fetchEntity(model: string, sourceID: string, snapshot: unknown): Promise<FetchedEntity>;
|
|
190
|
+
verifyEntity?(model: string, sourceID: string, snapshot: unknown): Promise<VerificationResult>;
|
|
149
191
|
reconcile(model: string, sourceID: string, snapshot: unknown): Promise<ReconcileResult>;
|
|
150
192
|
supportedStrategies: Array<'polling' | 'on-demand'>;
|
|
151
193
|
};
|
|
@@ -234,6 +276,14 @@ export type ActionWriteContext = {
|
|
|
234
276
|
* the write instead of being overwritten. Absent when unknown.
|
|
235
277
|
*/
|
|
236
278
|
providerETag?: string | null;
|
|
279
|
+
/**
|
|
280
|
+
* The target document's values that were trimmed to the model's caps when
|
|
281
|
+
* it was synced (see `TrimmedField`). A write that sends a field the caller
|
|
282
|
+
* did not set — one carried from the target document — refuses a trimmed
|
|
283
|
+
* one (`carryField`) rather than cut the provider's value to the stored
|
|
284
|
+
* prefix. Absent or empty when nothing was trimmed.
|
|
285
|
+
*/
|
|
286
|
+
trimmedFields?: ReadonlyArray<TrimmedField>;
|
|
237
287
|
};
|
|
238
288
|
export type ActionHandler = {
|
|
239
289
|
create(model: string, params: Record<string, unknown>, context?: ActionWriteContext): Promise<ActionWriteResult>;
|
|
@@ -393,8 +443,12 @@ export type SyncErrorEvent = {
|
|
|
393
443
|
};
|
|
394
444
|
export type SyncEvent = SyncProgressEvent | SyncCompleteEvent | SyncErrorEvent;
|
|
395
445
|
export type ConnectorSyncEventPayload = {
|
|
396
|
-
type: 'started' | 'progress' | 'completed' | 'error';
|
|
446
|
+
type: 'started' | 'progress' | 'completed' | 'error' | 'entity:trimmed';
|
|
397
447
|
connectorName: string;
|
|
448
|
+
modelID?: string;
|
|
449
|
+
documentID?: string;
|
|
450
|
+
sourceID?: string;
|
|
451
|
+
trimmedFields?: Array<TrimmedField>;
|
|
398
452
|
entitiesProcessed?: number;
|
|
399
453
|
entitiesFailed?: number;
|
|
400
454
|
totalProcessed?: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -15,10 +15,15 @@
|
|
|
15
15
|
"LICENSE.md"
|
|
16
16
|
],
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@
|
|
19
|
-
"@kubun/
|
|
20
|
-
"@kubun/
|
|
21
|
-
"
|
|
18
|
+
"@kokuin/token": "^0.5.1",
|
|
19
|
+
"@kubun/credential-types": "^0.18.0",
|
|
20
|
+
"@kubun/hlc": "^0.18.0",
|
|
21
|
+
"@kubun/id": "^0.18.0",
|
|
22
|
+
"@kubun/protocol": "^0.18.0",
|
|
23
|
+
"@kubun/records": "^0.18.0",
|
|
24
|
+
"@kubun/scalars": "^0.18.0",
|
|
25
|
+
"@sozai/codec": "^0.4.3",
|
|
26
|
+
"json-schema-typed": "^9.0.0"
|
|
22
27
|
},
|
|
23
28
|
"publishConfig": {
|
|
24
29
|
"access": "public"
|