@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 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 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, PendingOp, ReconcileResult, Recovery, ResolvedActionTarget, ServerDataProvider, ServerProviderFactory, SyncBoundary, SyncCompleteEvent, SyncErrorEvent, SyncEvent, SyncFailureSample, SyncProgressCASCurrent, SyncProgressCASResult, SyncProgressEvent, SyncProgressRecompute, SyncState, SyncStateEntry, SyncStateStore, SyncStatus, UserSyncPreferences, WebhookParams, WebhookRegistration, WriteAttachment, } from './types.js';
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.17.2",
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
- "@kubun/credential-types": "^0.17.2",
19
- "@kubun/protocol": "^0.17.1",
20
- "@kubun/scalars": "^0.17.0",
21
- "json-schema-typed": "^8.0.2"
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"