@rdlabo/workers-hono-kit 0.10.3 → 0.10.5

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.
@@ -14,3 +14,7 @@ export { decodeOfflineSnapshotCursor, encodeOfflineSnapshotCursor } from './snap
14
14
  export type { OfflineSnapshotCursor } from './snapshot-cursor.js';
15
15
  export { assertOfflineJournalCursorRetained, compactOfflineJournal, OfflineJournalRebaselineRequiredError, } from './journal-retention.js';
16
16
  export type { CompactOfflineJournalOptions, OfflineJournalRetentionCandidate, OfflineJournalRetentionStore, OfflineJournalRetentionTransaction, } from './journal-retention.js';
17
+ export { assertOfflineJournalCoverage, runOfflineJournalMutation } from './journal-mutation.js';
18
+ export type { OfflineJournalMutationChange, OfflineJournalMutationStore, OfflineJournalMutationTransaction, } from './journal-mutation.js';
19
+ export { defineOfflineWireCompatibility, resolveOfflineWireCompatibility } from './wire-compatibility.js';
20
+ export type { OfflineWireAcceptedFingerprint, OfflineWireCompatibility, OfflineWireCompatibilityResolution, OfflineWireFingerprint, } from './wire-compatibility.js';
@@ -11,3 +11,5 @@ export { replicaNowIso } from './clock.js';
11
11
  export { defineRestDbMethodConverter } from './rest-db-method-converter.js';
12
12
  export { decodeOfflineSnapshotCursor, encodeOfflineSnapshotCursor } from './snapshot-cursor.js';
13
13
  export { assertOfflineJournalCursorRetained, compactOfflineJournal, OfflineJournalRebaselineRequiredError, } from './journal-retention.js';
14
+ export { assertOfflineJournalCoverage, runOfflineJournalMutation } from './journal-mutation.js';
15
+ export { defineOfflineWireCompatibility, resolveOfflineWireCompatibility } from './wire-compatibility.js';
@@ -0,0 +1,44 @@
1
+ /** One product-owned replica target affected by a domain mutation. */
2
+ export interface OfflineJournalMutationChange<TScope, TSourceKey extends string = string> {
3
+ /** Product-defined authorization and replica partition. */
4
+ readonly scope: TScope;
5
+ /** Replica source whose hydrated value is invalidated by the mutation. */
6
+ readonly sourceKey: TSourceKey;
7
+ /** Stable server identity within the source. */
8
+ readonly serverId: string | number;
9
+ /** Optional Outbox acknowledgement metadata committed with the journal row. */
10
+ readonly command?: {
11
+ readonly userId: string | number;
12
+ readonly commandId?: string;
13
+ };
14
+ }
15
+ /** Transaction-bound adapter that persists journal changes beside the domain write. */
16
+ export interface OfflineJournalMutationTransaction<TScope, TSourceKey extends string = string> {
17
+ /** Appends one journal entry using the product transaction. */
18
+ append(change: OfflineJournalMutationChange<TScope, TSourceKey>): Promise<void>;
19
+ }
20
+ /** Product adapter that owns the database transaction and journal schema. */
21
+ export interface OfflineJournalMutationStore<TScope, TSourceKey extends string = string, TTransaction extends OfflineJournalMutationTransaction<TScope, TSourceKey> = OfflineJournalMutationTransaction<TScope, TSourceKey>> {
22
+ /**
23
+ * Runs the domain write and every journal append in one database transaction.
24
+ * A throw from either the domain mutation or `append` must roll back all writes.
25
+ */
26
+ transaction<TResult>(operation: (transaction: TTransaction) => Promise<TResult>): Promise<TResult>;
27
+ }
28
+ /**
29
+ * Commits one domain mutation and its complete replica journal footprint atomically.
30
+ *
31
+ * `mutate` returns both the business result and every affected replica target. The shared
32
+ * state machine appends all targets before the product transaction may commit.
33
+ */
34
+ export declare function runOfflineJournalMutation<TScope, TSourceKey extends string, TResult, TTransaction extends OfflineJournalMutationTransaction<TScope, TSourceKey>>(options: {
35
+ readonly store: OfflineJournalMutationStore<TScope, TSourceKey, TTransaction>;
36
+ readonly mutate: (transaction: TTransaction) => Promise<{
37
+ readonly result: TResult;
38
+ readonly changes: readonly OfflineJournalMutationChange<TScope, TSourceKey>[];
39
+ }>;
40
+ /** Returns the canonical authorization/partition key for one product scope. */
41
+ readonly scopeKey: (scope: TScope) => string;
42
+ }): Promise<TResult>;
43
+ /** Fails CI when a product write route has no declared journal coverage decision. */
44
+ export declare function assertOfflineJournalCoverage<TWriteId extends string>(writeIds: readonly TWriteId[], coverage: Readonly<Record<TWriteId, readonly string[] | 'not-replicated'>>): void;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Commits one domain mutation and its complete replica journal footprint atomically.
3
+ *
4
+ * `mutate` returns both the business result and every affected replica target. The shared
5
+ * state machine appends all targets before the product transaction may commit.
6
+ */
7
+ export function runOfflineJournalMutation(options) {
8
+ return options.store.transaction(async (transaction) => {
9
+ const { result, changes } = await options.mutate(transaction);
10
+ const unique = new Map();
11
+ for (const change of changes) {
12
+ const scopeKey = options.scopeKey(change.scope);
13
+ if (!scopeKey || !change.sourceKey || String(change.serverId).length === 0) {
14
+ throw new Error('Offline journal mutation scope, source, and server keys must be non-empty.');
15
+ }
16
+ const serverKey = typeof change.serverId === 'number' ? `number:${change.serverId}` : `string:${change.serverId}`;
17
+ const key = `${scopeKey}\u0000${change.sourceKey}\u0000${serverKey}`;
18
+ const existing = unique.get(key);
19
+ if (existing === undefined) {
20
+ unique.set(key, change);
21
+ continue;
22
+ }
23
+ if (existing.command === undefined && change.command !== undefined) {
24
+ unique.set(key, { ...existing, command: change.command });
25
+ continue;
26
+ }
27
+ if (existing.command !== undefined &&
28
+ change.command !== undefined &&
29
+ (String(existing.command.userId) !== String(change.command.userId) ||
30
+ existing.command.commandId !== change.command.commandId)) {
31
+ throw new Error(`Offline journal mutation key '${key}' has conflicting command metadata.`);
32
+ }
33
+ }
34
+ for (const change of unique.values()) {
35
+ await transaction.append(change);
36
+ }
37
+ return result;
38
+ });
39
+ }
40
+ /** Fails CI when a product write route has no declared journal coverage decision. */
41
+ export function assertOfflineJournalCoverage(writeIds, coverage) {
42
+ const declared = Object.keys(coverage);
43
+ const missing = writeIds.filter((writeId) => !Object.hasOwn(coverage, writeId));
44
+ const unknown = declared.filter((writeId) => !writeIds.includes(writeId));
45
+ if (missing.length > 0 || unknown.length > 0) {
46
+ throw new Error(`Offline journal coverage mismatch: missing=[${missing.join(',')}], unknown=[${unknown.join(',')}].`);
47
+ }
48
+ for (const writeId of writeIds) {
49
+ const decision = coverage[writeId];
50
+ if (decision === 'not-replicated') {
51
+ continue;
52
+ }
53
+ if (decision.length === 0 ||
54
+ decision.some((sourceKey) => sourceKey.trim().length === 0) ||
55
+ new Set(decision).size !== decision.length) {
56
+ throw new Error(`Offline journal coverage for '${writeId}' must contain unique, non-empty source keys or 'not-replicated'.`);
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Exact offline wire protocol identity: integer version plus content hash.
3
+ *
4
+ * Products own how the hash is computed; this kit never materializes schema
5
+ * projections or storage. Resolution always requires an exact `{version,hash}` pair.
6
+ */
7
+ export interface OfflineWireFingerprint {
8
+ /** Monotonic published protocol version. */
9
+ readonly version: number;
10
+ /** Content fingerprint for that published version. */
11
+ readonly hash: string;
12
+ }
13
+ /**
14
+ * A previously published fingerprint that remains accepted until an explicit expiry.
15
+ *
16
+ * Non-current entries must name the product adapter/projection that serves that wire
17
+ * shape. Silently allowlisting a hash alone is forbidden.
18
+ */
19
+ export interface OfflineWireAcceptedFingerprint extends OfflineWireFingerprint {
20
+ /** Instant at which acceptance ends (exclusive); later clocks reject the fingerprint. */
21
+ readonly expiresAt: Date;
22
+ /** Product-owned adapter or projection identifier for this prior wire shape. */
23
+ readonly adapterId: string;
24
+ }
25
+ /**
26
+ * Validated current fingerprint plus optional accepted prior fingerprints.
27
+ *
28
+ * Produced only by {@link defineOfflineWireCompatibility}.
29
+ */
30
+ export interface OfflineWireCompatibility {
31
+ /** The currently published protocol fingerprint. */
32
+ readonly current: OfflineWireFingerprint;
33
+ /** Prior fingerprints accepted until their explicit expiry. */
34
+ readonly accepted: readonly OfflineWireAcceptedFingerprint[];
35
+ }
36
+ /**
37
+ * Successful resolution of an incoming fingerprint against a compatibility table.
38
+ *
39
+ * Unmatched, expired, or inexact fingerprints resolve to `undefined` so products can map
40
+ * the miss to their own conflict response (for example HTTP 409).
41
+ */
42
+ export type OfflineWireCompatibilityResolution = {
43
+ readonly kind: 'current';
44
+ readonly fingerprint: OfflineWireFingerprint;
45
+ } | {
46
+ readonly kind: 'accepted';
47
+ readonly fingerprint: OfflineWireFingerprint;
48
+ readonly adapterId: string;
49
+ readonly expiresAt: Date;
50
+ };
51
+ /**
52
+ * Validates a current fingerprint plus optional accepted prior fingerprints.
53
+ *
54
+ * Rejects duplicate versions, duplicate hashes, invalid fingerprints, invalid expiry
55
+ * instants, and accepted entries that omit a product adapter/projection identifier.
56
+ *
57
+ * @param options - Current fingerprint and optional accepted prior fingerprints.
58
+ * @returns A validated compatibility table safe to pass to {@link resolveOfflineWireCompatibility}.
59
+ */
60
+ export declare function defineOfflineWireCompatibility(options: {
61
+ readonly current: OfflineWireFingerprint;
62
+ readonly accepted?: readonly OfflineWireAcceptedFingerprint[];
63
+ }): OfflineWireCompatibility;
64
+ /**
65
+ * Resolves an incoming `{version,hash}` against a validated compatibility table.
66
+ *
67
+ * Matches only exact fingerprints. Current always wins; accepted prior fingerprints match
68
+ * only while the injectable clock is strictly before their `expiresAt`.
69
+ *
70
+ * @param compatibility - Table from {@link defineOfflineWireCompatibility}.
71
+ * @param fingerprint - Incoming client fingerprint.
72
+ * @param clock - Injectable wall clock; defaults to the system wall clock.
73
+ * @returns The match, or `undefined` when the fingerprint is unknown, inexact, or expired.
74
+ */
75
+ export declare function resolveOfflineWireCompatibility(compatibility: OfflineWireCompatibility, fingerprint: OfflineWireFingerprint, clock?: () => Date): OfflineWireCompatibilityResolution | undefined;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Validates a current fingerprint plus optional accepted prior fingerprints.
3
+ *
4
+ * Rejects duplicate versions, duplicate hashes, invalid fingerprints, invalid expiry
5
+ * instants, and accepted entries that omit a product adapter/projection identifier.
6
+ *
7
+ * @param options - Current fingerprint and optional accepted prior fingerprints.
8
+ * @returns A validated compatibility table safe to pass to {@link resolveOfflineWireCompatibility}.
9
+ */
10
+ export function defineOfflineWireCompatibility(options) {
11
+ const current = normalizeFingerprint(options.current, 'current');
12
+ const acceptedInput = options.accepted ?? [];
13
+ const versions = new Set([current.version]);
14
+ const hashes = new Set([current.hash]);
15
+ const accepted = [];
16
+ for (const [index, entry] of acceptedInput.entries()) {
17
+ const label = `accepted[${index}]`;
18
+ const fingerprint = normalizeFingerprint(entry, label);
19
+ if (versions.has(fingerprint.version)) {
20
+ throw new Error(`Offline wire compatibility rejects duplicate version ${fingerprint.version}.`);
21
+ }
22
+ if (hashes.has(fingerprint.hash)) {
23
+ throw new Error(`Offline wire compatibility rejects duplicate hash '${fingerprint.hash}'.`);
24
+ }
25
+ if (!(entry.expiresAt instanceof Date) || Number.isNaN(entry.expiresAt.getTime())) {
26
+ throw new RangeError(`Offline wire compatibility ${label}.expiresAt must be a valid Date.`);
27
+ }
28
+ const adapterId = entry.adapterId;
29
+ if (typeof adapterId !== 'string' || adapterId.trim().length === 0) {
30
+ throw new Error(`Offline wire compatibility ${label} requires a non-empty product adapter/projection identifier.`);
31
+ }
32
+ versions.add(fingerprint.version);
33
+ hashes.add(fingerprint.hash);
34
+ accepted.push({
35
+ version: fingerprint.version,
36
+ hash: fingerprint.hash,
37
+ expiresAt: entry.expiresAt,
38
+ adapterId,
39
+ });
40
+ }
41
+ return { current, accepted };
42
+ }
43
+ /**
44
+ * Resolves an incoming `{version,hash}` against a validated compatibility table.
45
+ *
46
+ * Matches only exact fingerprints. Current always wins; accepted prior fingerprints match
47
+ * only while the injectable clock is strictly before their `expiresAt`.
48
+ *
49
+ * @param compatibility - Table from {@link defineOfflineWireCompatibility}.
50
+ * @param fingerprint - Incoming client fingerprint.
51
+ * @param clock - Injectable wall clock; defaults to the system wall clock.
52
+ * @returns The match, or `undefined` when the fingerprint is unknown, inexact, or expired.
53
+ */
54
+ export function resolveOfflineWireCompatibility(compatibility, fingerprint, clock = () => new Date()) {
55
+ const incoming = normalizeFingerprint(fingerprint, 'incoming');
56
+ if (incoming.version === compatibility.current.version) {
57
+ if (incoming.hash !== compatibility.current.hash) {
58
+ return undefined;
59
+ }
60
+ return { kind: 'current', fingerprint: compatibility.current };
61
+ }
62
+ const now = clock();
63
+ if (Number.isNaN(now.getTime())) {
64
+ throw new RangeError('Offline wire compatibility clock must return a valid Date.');
65
+ }
66
+ const nowMs = now.getTime();
67
+ for (const entry of compatibility.accepted) {
68
+ if (entry.version !== incoming.version) {
69
+ continue;
70
+ }
71
+ if (entry.hash !== incoming.hash) {
72
+ return undefined;
73
+ }
74
+ if (nowMs >= entry.expiresAt.getTime()) {
75
+ return undefined;
76
+ }
77
+ return {
78
+ kind: 'accepted',
79
+ fingerprint: { version: entry.version, hash: entry.hash },
80
+ adapterId: entry.adapterId,
81
+ expiresAt: entry.expiresAt,
82
+ };
83
+ }
84
+ return undefined;
85
+ }
86
+ function normalizeFingerprint(fingerprint, label) {
87
+ if (!Number.isSafeInteger(fingerprint.version) || fingerprint.version < 0) {
88
+ throw new RangeError(`Offline wire compatibility ${label}.version must be a non-negative safe integer.`);
89
+ }
90
+ if (typeof fingerprint.hash !== 'string' || fingerprint.hash.trim().length === 0) {
91
+ throw new Error(`Offline wire compatibility ${label}.hash must be a non-empty string.`);
92
+ }
93
+ return { version: fingerprint.version, hash: fingerprint.hash };
94
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rdlabo/workers-hono-kit",
3
- "version": "0.10.3",
3
+ "version": "0.10.5",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"