@totemsdk/edge-adapters 0.2.0 → 0.2.1
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/dist/artifact-cache.d.ts +36 -0
- package/dist/artifact-cache.js +82 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +15 -1
- package/dist/negotiation-transport.d.ts +50 -0
- package/dist/negotiation-transport.js +122 -0
- package/dist/purchase-authority.d.ts +42 -0
- package/dist/purchase-authority.js +79 -0
- package/dist/purchase-lookup.d.ts +44 -0
- package/dist/purchase-lookup.js +60 -0
- package/dist/purchase-payment.d.ts +78 -0
- package/dist/purchase-payment.js +108 -0
- package/dist/sqlite-commerce-store.d.ts +69 -0
- package/dist/sqlite-commerce-store.js +416 -0
- package/package.json +21 -16
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* edge-adapters/artifact-cache.ts — RFC-007 Amendment A, phase A1.
|
|
3
|
+
*
|
|
4
|
+
* The first consumer of the Edge persistence injection point (`PersistencePorts`).
|
|
5
|
+
* A scoped, content-addressed artifact cache over the injected `storage` (KV
|
|
6
|
+
* retention index) and `artifacts` (byte store) surfaces.
|
|
7
|
+
*
|
|
8
|
+
* Typed structurally against `@totemsdk/storage` — this module must NOT import
|
|
9
|
+
* `@totemsdk/edge`, so it carries no Edge runtime dependency.
|
|
10
|
+
*
|
|
11
|
+
* Guarantees:
|
|
12
|
+
* - `assertPersistence()` validates surfaces/capabilities at construction.
|
|
13
|
+
* - Scope is collision-control only (namespaces); authorization stays above.
|
|
14
|
+
* - Retrieval re-verifies digests via `ArtifactStore.get` (`corrupt` vs
|
|
15
|
+
* `not-found` are never conflated).
|
|
16
|
+
* - Retention/expiry entries survive restart when the injected storage is durable.
|
|
17
|
+
*/
|
|
18
|
+
import type { ArtifactBackendCapabilities, ArtifactRead, ArtifactRef, EffectivePersistence, PersistencePorts, PersistenceRequirement, StorageScope } from '@totemsdk/storage';
|
|
19
|
+
/**
|
|
20
|
+
* Per-object cache policy. Deliberately does NOT reuse the `retention:
|
|
21
|
+
* 'fixed'|'managed'|'none'` vocabulary: that is a BACKEND capability, not a
|
|
22
|
+
* per-object request.
|
|
23
|
+
*/
|
|
24
|
+
export interface ArtifactCacheRetentionPolicy {
|
|
25
|
+
readonly ttlMs?: number;
|
|
26
|
+
readonly invalidateOnExpiry?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface EdgeArtifactCache {
|
|
29
|
+
readonly capabilities: ArtifactBackendCapabilities;
|
|
30
|
+
/** Reported after assertPersistence(); callers must never assume durability. */
|
|
31
|
+
readonly effective: EffectivePersistence;
|
|
32
|
+
put(scope: StorageScope, bytes: Uint8Array, policy?: ArtifactCacheRetentionPolicy): Promise<ArtifactRef>;
|
|
33
|
+
get(scope: StorageScope, ref: ArtifactRef, policy?: ArtifactCacheRetentionPolicy): Promise<ArtifactRead>;
|
|
34
|
+
invalidate(scope: StorageScope, ref: ArtifactRef): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export declare function createEdgeArtifactCache(ports: PersistencePorts, requirement?: PersistenceRequirement, policy?: ArtifactCacheRetentionPolicy): EdgeArtifactCache;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* edge-adapters/artifact-cache.ts — RFC-007 Amendment A, phase A1.
|
|
4
|
+
*
|
|
5
|
+
* The first consumer of the Edge persistence injection point (`PersistencePorts`).
|
|
6
|
+
* A scoped, content-addressed artifact cache over the injected `storage` (KV
|
|
7
|
+
* retention index) and `artifacts` (byte store) surfaces.
|
|
8
|
+
*
|
|
9
|
+
* Typed structurally against `@totemsdk/storage` — this module must NOT import
|
|
10
|
+
* `@totemsdk/edge`, so it carries no Edge runtime dependency.
|
|
11
|
+
*
|
|
12
|
+
* Guarantees:
|
|
13
|
+
* - `assertPersistence()` validates surfaces/capabilities at construction.
|
|
14
|
+
* - Scope is collision-control only (namespaces); authorization stays above.
|
|
15
|
+
* - Retrieval re-verifies digests via `ArtifactStore.get` (`corrupt` vs
|
|
16
|
+
* `not-found` are never conflated).
|
|
17
|
+
* - Retention/expiry entries survive restart when the injected storage is durable.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.createEdgeArtifactCache = createEdgeArtifactCache;
|
|
21
|
+
const storage_1 = require("@totemsdk/storage");
|
|
22
|
+
/** Scope-derived artifact namespace: separate scopes never alias the same blob. */
|
|
23
|
+
function scopeNamespace(scope) {
|
|
24
|
+
const parts = [scope.principal, scope.adapter, scope.purpose, scope.domain]
|
|
25
|
+
.filter((p) => typeof p === 'string' && p.length > 0)
|
|
26
|
+
.map((p) => encodeURIComponent(p));
|
|
27
|
+
return `edge-cache:${parts.join('/')}`;
|
|
28
|
+
}
|
|
29
|
+
function createEdgeArtifactCache(ports, requirement = { durable: false }, policy = {}) {
|
|
30
|
+
const label = 'edge-artifact-cache';
|
|
31
|
+
// The cache always needs both surfaces and a deletable artifact backend.
|
|
32
|
+
const effective = (0, storage_1.assertPersistence)(ports, { ...requirement, needsArtifacts: true, needsArtifactDelete: true }, label);
|
|
33
|
+
const storage = ports.storage;
|
|
34
|
+
const artifacts = ports.artifacts;
|
|
35
|
+
if (!storage) {
|
|
36
|
+
throw new storage_1.StorageError(`${label}: a storage index is required for retention/expiry`, 'unavailable');
|
|
37
|
+
}
|
|
38
|
+
if (!artifacts) {
|
|
39
|
+
throw new storage_1.StorageError(`${label}: an artifact store is required`, 'unavailable');
|
|
40
|
+
}
|
|
41
|
+
const defaultTtlMs = policy.ttlMs;
|
|
42
|
+
const defaultInvalidateOnExpiry = policy.invalidateOnExpiry ?? false;
|
|
43
|
+
const now = () => Date.now();
|
|
44
|
+
const scoped = (scope) => (0, storage_1.createScopedStorage)(storage, scope);
|
|
45
|
+
return {
|
|
46
|
+
capabilities: artifacts.capabilities,
|
|
47
|
+
effective,
|
|
48
|
+
async put(scope, bytes, putPolicy) {
|
|
49
|
+
const ttlMs = putPolicy?.ttlMs ?? defaultTtlMs;
|
|
50
|
+
const receipt = await artifacts.put(scopeNamespace(scope), bytes, {
|
|
51
|
+
...(ttlMs !== undefined ? { retentionMs: ttlMs } : {}),
|
|
52
|
+
});
|
|
53
|
+
const entry = {
|
|
54
|
+
ref: receipt.ref,
|
|
55
|
+
size: receipt.size,
|
|
56
|
+
createdAt: now(),
|
|
57
|
+
...(ttlMs !== undefined ? { expiresAt: now() + ttlMs } : {}),
|
|
58
|
+
};
|
|
59
|
+
await scoped(scope).set((0, storage_1.artifactRefId)(receipt.ref), entry);
|
|
60
|
+
return receipt.ref;
|
|
61
|
+
},
|
|
62
|
+
async get(scope, ref, getPolicy) {
|
|
63
|
+
const entry = await scoped(scope).get((0, storage_1.artifactRefId)(ref));
|
|
64
|
+
if (!entry) {
|
|
65
|
+
// Not known to this scope — never fall through to the raw backend.
|
|
66
|
+
return { status: 'not-found' };
|
|
67
|
+
}
|
|
68
|
+
if (entry.expiresAt !== undefined && now() > entry.expiresAt) {
|
|
69
|
+
const invalidate = getPolicy?.invalidateOnExpiry ?? defaultInvalidateOnExpiry;
|
|
70
|
+
if (invalidate)
|
|
71
|
+
await this.invalidate(scope, ref);
|
|
72
|
+
return { status: 'not-found' };
|
|
73
|
+
}
|
|
74
|
+
// Digest re-verification + corrupt/not-found taxonomy live in ArtifactStore.
|
|
75
|
+
return artifacts.get(ref);
|
|
76
|
+
},
|
|
77
|
+
async invalidate(scope, ref) {
|
|
78
|
+
await artifacts.delete(ref);
|
|
79
|
+
await scoped(scope).remove((0, storage_1.artifactRefId)(ref));
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ export type { LiquidityPortConfig } from './liquidity.js';
|
|
|
3
3
|
export { createProofPortAdapter } from './proof.js';
|
|
4
4
|
export type { ProofPortConfig } from './proof.js';
|
|
5
5
|
export { createLookupPortAdapter } from './lookup.js';
|
|
6
|
+
export { createPurchaseLookupAdapter, type PurchaseLookupAdapterConfig } from './purchase-lookup.js';
|
|
7
|
+
export { createPurchaseAuthorityAdapter, type PurchaseAuthorityAdapterConfig } from './purchase-authority.js';
|
|
8
|
+
export { createPurchasePaymentAdapter, type PurchasePaymentAdapterConfig, type PurchasePaymentStore, type PurchasePaymentClaimRecord, } from './purchase-payment.js';
|
|
9
|
+
export { createStreamNegotiationTransport, createPubSubNegotiationTransport, type StreamNegotiationTransportConfig, type PubSubNegotiationTransportConfig, } from './negotiation-transport.js';
|
|
6
10
|
export { createLocationPortAdapter } from './location.js';
|
|
7
11
|
export type { LocationPortConfig } from './location.js';
|
|
8
12
|
export { createPolicyPortAdapter } from './policy.js';
|
|
@@ -17,3 +21,5 @@ export { createOmniaHostPort } from './omnia.js';
|
|
|
17
21
|
export type { OmniaHostPortConfig } from './omnia.js';
|
|
18
22
|
export { createStreamPortAdapter } from './stream.js';
|
|
19
23
|
export { createPubSubPortAdapter } from './pubsub.js';
|
|
24
|
+
export { SQLiteCommerceStore, createSQLiteCommerceStore, type CommerceStore, type SQLiteCommerceStoreConfig, } from './sqlite-commerce-store.js';
|
|
25
|
+
export { createEdgeArtifactCache, type EdgeArtifactCache, type ArtifactCacheRetentionPolicy, } from './artifact-cache.js';
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.createPubSubPortAdapter = exports.createStreamPortAdapter = exports.createOmniaHostPort = exports.createOmniaL2PaymentPort = exports.createMinimaL1PaymentPort = exports.createManifestPortAdapter = exports.createIdentityPortAdapter = exports.createPolicyPortAdapter = exports.createLocationPortAdapter = exports.createLookupPortAdapter = exports.createProofPortAdapter = exports.createLiquidityPortAdapter = void 0;
|
|
3
|
+
exports.createEdgeArtifactCache = exports.createSQLiteCommerceStore = exports.SQLiteCommerceStore = exports.createPubSubPortAdapter = exports.createStreamPortAdapter = exports.createOmniaHostPort = exports.createOmniaL2PaymentPort = exports.createMinimaL1PaymentPort = exports.createManifestPortAdapter = exports.createIdentityPortAdapter = exports.createPolicyPortAdapter = exports.createLocationPortAdapter = exports.createPubSubNegotiationTransport = exports.createStreamNegotiationTransport = exports.createPurchasePaymentAdapter = exports.createPurchaseAuthorityAdapter = exports.createPurchaseLookupAdapter = exports.createLookupPortAdapter = exports.createProofPortAdapter = exports.createLiquidityPortAdapter = void 0;
|
|
4
4
|
var liquidity_js_1 = require("./liquidity.js");
|
|
5
5
|
Object.defineProperty(exports, "createLiquidityPortAdapter", { enumerable: true, get: function () { return liquidity_js_1.createLiquidityPortAdapter; } });
|
|
6
6
|
var proof_js_1 = require("./proof.js");
|
|
7
7
|
Object.defineProperty(exports, "createProofPortAdapter", { enumerable: true, get: function () { return proof_js_1.createProofPortAdapter; } });
|
|
8
8
|
var lookup_js_1 = require("./lookup.js");
|
|
9
9
|
Object.defineProperty(exports, "createLookupPortAdapter", { enumerable: true, get: function () { return lookup_js_1.createLookupPortAdapter; } });
|
|
10
|
+
var purchase_lookup_js_1 = require("./purchase-lookup.js");
|
|
11
|
+
Object.defineProperty(exports, "createPurchaseLookupAdapter", { enumerable: true, get: function () { return purchase_lookup_js_1.createPurchaseLookupAdapter; } });
|
|
12
|
+
var purchase_authority_js_1 = require("./purchase-authority.js");
|
|
13
|
+
Object.defineProperty(exports, "createPurchaseAuthorityAdapter", { enumerable: true, get: function () { return purchase_authority_js_1.createPurchaseAuthorityAdapter; } });
|
|
14
|
+
var purchase_payment_js_1 = require("./purchase-payment.js");
|
|
15
|
+
Object.defineProperty(exports, "createPurchasePaymentAdapter", { enumerable: true, get: function () { return purchase_payment_js_1.createPurchasePaymentAdapter; } });
|
|
16
|
+
var negotiation_transport_js_1 = require("./negotiation-transport.js");
|
|
17
|
+
Object.defineProperty(exports, "createStreamNegotiationTransport", { enumerable: true, get: function () { return negotiation_transport_js_1.createStreamNegotiationTransport; } });
|
|
18
|
+
Object.defineProperty(exports, "createPubSubNegotiationTransport", { enumerable: true, get: function () { return negotiation_transport_js_1.createPubSubNegotiationTransport; } });
|
|
10
19
|
var location_js_1 = require("./location.js");
|
|
11
20
|
Object.defineProperty(exports, "createLocationPortAdapter", { enumerable: true, get: function () { return location_js_1.createLocationPortAdapter; } });
|
|
12
21
|
var policy_js_1 = require("./policy.js");
|
|
@@ -25,3 +34,8 @@ var stream_js_1 = require("./stream.js");
|
|
|
25
34
|
Object.defineProperty(exports, "createStreamPortAdapter", { enumerable: true, get: function () { return stream_js_1.createStreamPortAdapter; } });
|
|
26
35
|
var pubsub_js_1 = require("./pubsub.js");
|
|
27
36
|
Object.defineProperty(exports, "createPubSubPortAdapter", { enumerable: true, get: function () { return pubsub_js_1.createPubSubPortAdapter; } });
|
|
37
|
+
var sqlite_commerce_store_js_1 = require("./sqlite-commerce-store.js");
|
|
38
|
+
Object.defineProperty(exports, "SQLiteCommerceStore", { enumerable: true, get: function () { return sqlite_commerce_store_js_1.SQLiteCommerceStore; } });
|
|
39
|
+
Object.defineProperty(exports, "createSQLiteCommerceStore", { enumerable: true, get: function () { return sqlite_commerce_store_js_1.createSQLiteCommerceStore; } });
|
|
40
|
+
var artifact_cache_js_1 = require("./artifact-cache.js");
|
|
41
|
+
Object.defineProperty(exports, "createEdgeArtifactCache", { enumerable: true, get: function () { return artifact_cache_js_1.createEdgeArtifactCache; } });
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* negotiation-transport.ts — NegotiationTransport adapters over Edge ports.
|
|
3
|
+
*
|
|
4
|
+
* Two adapters:
|
|
5
|
+
* - createStreamNegotiationTransport: raw byte stream (EdgeStreamPort).
|
|
6
|
+
* Messages are framed as length-prefixed JSON. The sender address is
|
|
7
|
+
* resolved by the caller (the stream is a single authenticated peer).
|
|
8
|
+
* - createPubSubNegotiationTransport: topic-based (EdgePubSubPort).
|
|
9
|
+
* Each negotiation uses a topic derived from the negotiationId; the
|
|
10
|
+
* sender address is carried in the envelope.
|
|
11
|
+
*
|
|
12
|
+
* Transport metadata stays separate from economic protocol objects. The
|
|
13
|
+
* NegotiationEngine never sees sockets/topics/connection IDs.
|
|
14
|
+
*/
|
|
15
|
+
import type { NegotiationTransport } from '@totemsdk/edge';
|
|
16
|
+
import type { EdgePubSubPort, EdgeStreamPort } from '@totemsdk/edge';
|
|
17
|
+
export interface StreamNegotiationTransportConfig {
|
|
18
|
+
stream: EdgeStreamPort;
|
|
19
|
+
/** The authenticated sender address for this stream (resolved by caller). */
|
|
20
|
+
sender: string;
|
|
21
|
+
/** The local recipient address. */
|
|
22
|
+
recipient: string;
|
|
23
|
+
/** Max message size in bytes (default 64 KiB). */
|
|
24
|
+
maxBytes?: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Create a NegotiationTransport over a raw byte stream.
|
|
28
|
+
*
|
|
29
|
+
* Messages are framed as `[4-byte BE length][JSON bytes]`. The stream is
|
|
30
|
+
* assumed to be a single authenticated peer connection, so the sender address
|
|
31
|
+
* is fixed by the caller.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createStreamNegotiationTransport(config: StreamNegotiationTransportConfig): NegotiationTransport;
|
|
34
|
+
export interface PubSubNegotiationTransportConfig {
|
|
35
|
+
pubsub: EdgePubSubPort;
|
|
36
|
+
/** The local recipient address. */
|
|
37
|
+
recipient: string;
|
|
38
|
+
/** Topic prefix (default 'totem.negotiation'). */
|
|
39
|
+
topicPrefix?: string;
|
|
40
|
+
/** Max message size in bytes (default 64 KiB). */
|
|
41
|
+
maxBytes?: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Create a NegotiationTransport over a pub/sub transport.
|
|
45
|
+
*
|
|
46
|
+
* Each negotiation uses a topic derived from its negotiationId. The sender
|
|
47
|
+
* address is carried in the envelope (a `sender` field on the message), so
|
|
48
|
+
* the ingress pipeline can authenticate it.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createPubSubNegotiationTransport(config: PubSubNegotiationTransportConfig): NegotiationTransport;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* negotiation-transport.ts — NegotiationTransport adapters over Edge ports.
|
|
4
|
+
*
|
|
5
|
+
* Two adapters:
|
|
6
|
+
* - createStreamNegotiationTransport: raw byte stream (EdgeStreamPort).
|
|
7
|
+
* Messages are framed as length-prefixed JSON. The sender address is
|
|
8
|
+
* resolved by the caller (the stream is a single authenticated peer).
|
|
9
|
+
* - createPubSubNegotiationTransport: topic-based (EdgePubSubPort).
|
|
10
|
+
* Each negotiation uses a topic derived from the negotiationId; the
|
|
11
|
+
* sender address is carried in the envelope.
|
|
12
|
+
*
|
|
13
|
+
* Transport metadata stays separate from economic protocol objects. The
|
|
14
|
+
* NegotiationEngine never sees sockets/topics/connection IDs.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.createStreamNegotiationTransport = createStreamNegotiationTransport;
|
|
18
|
+
exports.createPubSubNegotiationTransport = createPubSubNegotiationTransport;
|
|
19
|
+
/**
|
|
20
|
+
* Create a NegotiationTransport over a raw byte stream.
|
|
21
|
+
*
|
|
22
|
+
* Messages are framed as `[4-byte BE length][JSON bytes]`. The stream is
|
|
23
|
+
* assumed to be a single authenticated peer connection, so the sender address
|
|
24
|
+
* is fixed by the caller.
|
|
25
|
+
*/
|
|
26
|
+
function createStreamNegotiationTransport(config) {
|
|
27
|
+
const { stream, sender, recipient } = config;
|
|
28
|
+
const maxBytes = config.maxBytes ?? 64 * 1024;
|
|
29
|
+
return {
|
|
30
|
+
async send(_recipient, message) {
|
|
31
|
+
const bytes = new TextEncoder().encode(JSON.stringify(message));
|
|
32
|
+
if (bytes.length > maxBytes) {
|
|
33
|
+
throw new Error(`negotiation message exceeds ${maxBytes} bytes`);
|
|
34
|
+
}
|
|
35
|
+
const frame = new Uint8Array(4 + bytes.length);
|
|
36
|
+
new DataView(frame.buffer).setUint32(0, bytes.length);
|
|
37
|
+
frame.set(bytes, 4);
|
|
38
|
+
// Raw byte-stream send does NOT prove durable remote processing —
|
|
39
|
+
// return no receipt so the outbox drainer does not mark delivered.
|
|
40
|
+
stream.send(frame);
|
|
41
|
+
return undefined;
|
|
42
|
+
},
|
|
43
|
+
subscribe(handler) {
|
|
44
|
+
let buffer = new Uint8Array(0);
|
|
45
|
+
const off = stream.onData((chunk) => {
|
|
46
|
+
buffer = concatBytes(buffer, chunk);
|
|
47
|
+
while (buffer.length >= 4) {
|
|
48
|
+
const view = new DataView(buffer.buffer, buffer.byteOffset, 4);
|
|
49
|
+
const len = view.getUint32(0);
|
|
50
|
+
if (buffer.length < 4 + len)
|
|
51
|
+
break;
|
|
52
|
+
const payload = buffer.slice(4, 4 + len);
|
|
53
|
+
buffer = buffer.slice(4 + len);
|
|
54
|
+
try {
|
|
55
|
+
const message = JSON.parse(new TextDecoder().decode(payload));
|
|
56
|
+
void handler(message, { sender, recipient });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Malformed frame — drop.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
return off;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function topicFor(topicPrefix, negotiationId) {
|
|
68
|
+
return `${topicPrefix}.${negotiationId}`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Create a NegotiationTransport over a pub/sub transport.
|
|
72
|
+
*
|
|
73
|
+
* Each negotiation uses a topic derived from its negotiationId. The sender
|
|
74
|
+
* address is carried in the envelope (a `sender` field on the message), so
|
|
75
|
+
* the ingress pipeline can authenticate it.
|
|
76
|
+
*/
|
|
77
|
+
function createPubSubNegotiationTransport(config) {
|
|
78
|
+
const { pubsub, recipient } = config;
|
|
79
|
+
const topicPrefix = config.topicPrefix ?? 'totem.negotiation';
|
|
80
|
+
const maxBytes = config.maxBytes ?? 64 * 1024;
|
|
81
|
+
return {
|
|
82
|
+
async send(recipientAddress, message) {
|
|
83
|
+
const negotiationId = message.negotiationId;
|
|
84
|
+
if (!negotiationId)
|
|
85
|
+
throw new Error('negotiation message missing negotiationId');
|
|
86
|
+
const topic = topicFor(topicPrefix, negotiationId);
|
|
87
|
+
const envelope = { ...message, sender: recipientAddress, recipient };
|
|
88
|
+
const bytes = new TextEncoder().encode(JSON.stringify(envelope));
|
|
89
|
+
if (bytes.length > maxBytes) {
|
|
90
|
+
throw new Error(`negotiation message exceeds ${maxBytes} bytes`);
|
|
91
|
+
}
|
|
92
|
+
// Pub/sub publish() proves local broker transmission, not durable remote
|
|
93
|
+
// processing — return no receipt so the outbox drainer does not mark
|
|
94
|
+
// delivered on local send alone.
|
|
95
|
+
await pubsub.publish(topic, bytes);
|
|
96
|
+
return undefined;
|
|
97
|
+
},
|
|
98
|
+
subscribe(handler) {
|
|
99
|
+
return pubsub.onMessage(({ topic, payload }) => {
|
|
100
|
+
if (!topic.startsWith(topicPrefix))
|
|
101
|
+
return;
|
|
102
|
+
try {
|
|
103
|
+
const envelope = JSON.parse(new TextDecoder().decode(payload));
|
|
104
|
+
const sender = envelope.sender ?? 'unknown';
|
|
105
|
+
void handler(envelope, { sender, recipient });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Malformed message — drop.
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
115
|
+
// Helpers
|
|
116
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
117
|
+
function concatBytes(a, b) {
|
|
118
|
+
const out = new Uint8Array(a.length + b.length);
|
|
119
|
+
out.set(a, 0);
|
|
120
|
+
out.set(b, a.length);
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* purchase-authority.ts — AuthorityPort adapter over @totemsdk/agent-policy.
|
|
3
|
+
*
|
|
4
|
+
* Maps a machine-commerce TradeAgreement + PurchaseIntent to an AgentProposal
|
|
5
|
+
* and evaluates it through a ComposablePolicy (or any AgentPolicy).
|
|
6
|
+
*
|
|
7
|
+
* Architectural boundary preserved:
|
|
8
|
+
* TradeProposal = machine ↔ machine bargaining (peer-to-peer)
|
|
9
|
+
* AgentProposal = agent → trusted local authority (wallet/policy)
|
|
10
|
+
*
|
|
11
|
+
* The machine negotiation strategy never receives a private key. This adapter
|
|
12
|
+
* is the trusted boundary that decides whether the local authority approves
|
|
13
|
+
* the economic commitment.
|
|
14
|
+
*/
|
|
15
|
+
import type { AgentPolicy, PolicyMiddleware } from '@totemsdk/agent-policy';
|
|
16
|
+
import type { EdgeOperationResult } from '@totemsdk/edge';
|
|
17
|
+
import type { PurchaseIntent, TradeAgreement } from '@totemsdk/edge';
|
|
18
|
+
type PolicyLike = AgentPolicy | PolicyMiddleware;
|
|
19
|
+
export interface PurchaseAuthorityAdapterConfig {
|
|
20
|
+
/** The local policy (ComposablePolicy, AgentPolicy, or PolicyMiddleware). */
|
|
21
|
+
policy: PolicyLike;
|
|
22
|
+
/** Agent identifier used in the AgentProposal (e.g. 'edge-purchase-agent'). */
|
|
23
|
+
agentId?: string;
|
|
24
|
+
/** Optional risk level for the proposal. */
|
|
25
|
+
risk?: 'low' | 'medium' | 'high';
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Create an AuthorityPort adapter over an agent-policy.
|
|
29
|
+
*
|
|
30
|
+
* approve() builds an AgentProposal from the agreement + intent and evaluates
|
|
31
|
+
* it through the policy. Returns `{ allowed, reason }`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createPurchaseAuthorityAdapter(config: PurchaseAuthorityAdapterConfig): {
|
|
34
|
+
approve(params: {
|
|
35
|
+
agreement: TradeAgreement;
|
|
36
|
+
intent: PurchaseIntent;
|
|
37
|
+
}): Promise<EdgeOperationResult<{
|
|
38
|
+
allowed: boolean;
|
|
39
|
+
reason?: string;
|
|
40
|
+
}>>;
|
|
41
|
+
};
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* purchase-authority.ts — AuthorityPort adapter over @totemsdk/agent-policy.
|
|
4
|
+
*
|
|
5
|
+
* Maps a machine-commerce TradeAgreement + PurchaseIntent to an AgentProposal
|
|
6
|
+
* and evaluates it through a ComposablePolicy (or any AgentPolicy).
|
|
7
|
+
*
|
|
8
|
+
* Architectural boundary preserved:
|
|
9
|
+
* TradeProposal = machine ↔ machine bargaining (peer-to-peer)
|
|
10
|
+
* AgentProposal = agent → trusted local authority (wallet/policy)
|
|
11
|
+
*
|
|
12
|
+
* The machine negotiation strategy never receives a private key. This adapter
|
|
13
|
+
* is the trusted boundary that decides whether the local authority approves
|
|
14
|
+
* the economic commitment.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.createPurchaseAuthorityAdapter = createPurchaseAuthorityAdapter;
|
|
18
|
+
function isMiddleware(p) {
|
|
19
|
+
return 'evaluate' in p && typeof p.evaluate === 'function';
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Create an AuthorityPort adapter over an agent-policy.
|
|
23
|
+
*
|
|
24
|
+
* approve() builds an AgentProposal from the agreement + intent and evaluates
|
|
25
|
+
* it through the policy. Returns `{ allowed, reason }`.
|
|
26
|
+
*/
|
|
27
|
+
function createPurchaseAuthorityAdapter(config) {
|
|
28
|
+
const { policy } = config;
|
|
29
|
+
const agentId = config.agentId ?? 'edge-purchase-agent';
|
|
30
|
+
const risk = config.risk ?? 'medium';
|
|
31
|
+
return {
|
|
32
|
+
async approve(params) {
|
|
33
|
+
try {
|
|
34
|
+
const { agreement, intent } = params;
|
|
35
|
+
const proposal = {
|
|
36
|
+
id: `edge-purchase:${agreement.agreementId}`,
|
|
37
|
+
agentId,
|
|
38
|
+
intent: {
|
|
39
|
+
type: 'payment',
|
|
40
|
+
amount: agreement.terms.price,
|
|
41
|
+
tokenId: agreement.terms.tokenId,
|
|
42
|
+
recipient: agreement.seller,
|
|
43
|
+
reason: `purchase ${intent.resource} from ${agreement.seller}`,
|
|
44
|
+
risk,
|
|
45
|
+
metadata: {
|
|
46
|
+
agreementId: agreement.agreementId,
|
|
47
|
+
negotiationId: agreement.negotiationId,
|
|
48
|
+
resource: intent.resource,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
explanation: `Machine purchase of ${intent.resource} for ${agreement.terms.price} ${agreement.terms.tokenId ?? '0x00'} from ${agreement.seller}`,
|
|
52
|
+
confidence: 1,
|
|
53
|
+
createdAt: Date.now(),
|
|
54
|
+
};
|
|
55
|
+
if (isMiddleware(policy)) {
|
|
56
|
+
const result = await policy.evaluate(proposal);
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
data: {
|
|
60
|
+
allowed: result.outcome === 'approved',
|
|
61
|
+
reason: result.reason,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const allowed = await policy.canAutoApprove(proposal);
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
data: {
|
|
69
|
+
allowed,
|
|
70
|
+
reason: allowed ? undefined : 'Purchase requires user approval per configured policy',
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* purchase-lookup.ts — PurchaseLookupPort adapter over @totemsdk/lookup-client.
|
|
3
|
+
*
|
|
4
|
+
* Maps a machine-commerce `resource` (e.g. "compute", "storage") to a
|
|
5
|
+
* capability query on the Lookup Node, returning signed manifests for the
|
|
6
|
+
* buyer's discovery flow.
|
|
7
|
+
*
|
|
8
|
+
* Discovery is publication, not authority: the buyer MUST still verify each
|
|
9
|
+
* returned manifest's signature, identity binding, expiry, and capabilities
|
|
10
|
+
* before negotiation/payment. This adapter only fetches candidates.
|
|
11
|
+
*/
|
|
12
|
+
import type { LookupClient } from '@totemsdk/lookup-client';
|
|
13
|
+
import type { EdgeOperationResult } from '@totemsdk/edge';
|
|
14
|
+
export interface PurchaseLookupAdapterConfig {
|
|
15
|
+
client: LookupClient;
|
|
16
|
+
/** Optional provider address filter (authorAddress for apps). */
|
|
17
|
+
provider?: string;
|
|
18
|
+
/** Optional max price filter (agents). */
|
|
19
|
+
maxPricePerCall?: number;
|
|
20
|
+
/** Optional max latency filter (agents). */
|
|
21
|
+
maxLatencyMs?: number;
|
|
22
|
+
/** Optional result limit. */
|
|
23
|
+
limit?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Create a PurchaseLookupPort adapter over a LookupClient.
|
|
27
|
+
*
|
|
28
|
+
* `resource` is treated as a capability name for agent manifests (the
|
|
29
|
+
* machine-commerce case). If the resource matches a known app category, it
|
|
30
|
+
* falls back to app queries. Returns `{ id, manifest: Uint8Array, nodeId }`
|
|
31
|
+
* candidates for the buyer's manifest-verification pipeline.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createPurchaseLookupAdapter(config: PurchaseLookupAdapterConfig): {
|
|
34
|
+
query(params: {
|
|
35
|
+
resource: string;
|
|
36
|
+
provider?: string;
|
|
37
|
+
}): Promise<EdgeOperationResult<{
|
|
38
|
+
results: Array<{
|
|
39
|
+
id: string;
|
|
40
|
+
manifest: Uint8Array;
|
|
41
|
+
nodeId: string;
|
|
42
|
+
}>;
|
|
43
|
+
}>>;
|
|
44
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* purchase-lookup.ts — PurchaseLookupPort adapter over @totemsdk/lookup-client.
|
|
4
|
+
*
|
|
5
|
+
* Maps a machine-commerce `resource` (e.g. "compute", "storage") to a
|
|
6
|
+
* capability query on the Lookup Node, returning signed manifests for the
|
|
7
|
+
* buyer's discovery flow.
|
|
8
|
+
*
|
|
9
|
+
* Discovery is publication, not authority: the buyer MUST still verify each
|
|
10
|
+
* returned manifest's signature, identity binding, expiry, and capabilities
|
|
11
|
+
* before negotiation/payment. This adapter only fetches candidates.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.createPurchaseLookupAdapter = createPurchaseLookupAdapter;
|
|
15
|
+
/**
|
|
16
|
+
* Create a PurchaseLookupPort adapter over a LookupClient.
|
|
17
|
+
*
|
|
18
|
+
* `resource` is treated as a capability name for agent manifests (the
|
|
19
|
+
* machine-commerce case). If the resource matches a known app category, it
|
|
20
|
+
* falls back to app queries. Returns `{ id, manifest: Uint8Array, nodeId }`
|
|
21
|
+
* candidates for the buyer's manifest-verification pipeline.
|
|
22
|
+
*/
|
|
23
|
+
function createPurchaseLookupAdapter(config) {
|
|
24
|
+
const { client } = config;
|
|
25
|
+
return {
|
|
26
|
+
async query(params) {
|
|
27
|
+
try {
|
|
28
|
+
const provider = params.provider ?? config.provider;
|
|
29
|
+
const limit = config.limit ?? 20;
|
|
30
|
+
// Agent capability query (machine-commerce primary path).
|
|
31
|
+
const agents = await client.queryAgents({
|
|
32
|
+
capabilityName: params.resource,
|
|
33
|
+
maxPricePerCall: config.maxPricePerCall,
|
|
34
|
+
maxLatencyMs: config.maxLatencyMs,
|
|
35
|
+
limit,
|
|
36
|
+
});
|
|
37
|
+
const results = agents.map((a) => ({
|
|
38
|
+
id: a.capabilityId,
|
|
39
|
+
manifest: a.manifest,
|
|
40
|
+
nodeId: a.nodeId,
|
|
41
|
+
}));
|
|
42
|
+
// If no agent results and a provider is specified, also try app query
|
|
43
|
+
// filtered by author address (provider = operator).
|
|
44
|
+
if (results.length === 0 && provider) {
|
|
45
|
+
const apps = await client.queryApps({
|
|
46
|
+
authorAddress: provider,
|
|
47
|
+
limit,
|
|
48
|
+
});
|
|
49
|
+
for (const a of apps) {
|
|
50
|
+
results.push({ id: a.appId, manifest: a.manifest, nodeId: a.nodeId });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, data: { results } };
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* purchase-payment.ts — PurchasePaymentPort adapter with an atomic claim.
|
|
3
|
+
*
|
|
4
|
+
* Wraps any EdgePaymentPort (L1 Minima, L2 Omnia, hosted) and adds a stable
|
|
5
|
+
* idempotency-key claim so a retry after a lost response never double-pays.
|
|
6
|
+
*
|
|
7
|
+
* The idempotency key is the stable purchase operation identity
|
|
8
|
+
* (`purchaseId:agreementId:payment`) supplied by the purchasing layer. The
|
|
9
|
+
* wrapper records an atomic pending marker (claim) *before* it invokes the
|
|
10
|
+
* underlying payment port; only the caller that wins the claim pays. A retry
|
|
11
|
+
* with the same key returns the prior result without re-invoking the port, and
|
|
12
|
+
* a concurrent duplicate never double-pays — it either replays the completed
|
|
13
|
+
* outcome or surfaces `PAYMENT_STATE_UNKNOWN` for an in-flight claim.
|
|
14
|
+
*
|
|
15
|
+
* Final exactly-once still depends on the payment system's own reconciliation:
|
|
16
|
+
* if the process dies between the port accepting the payment and the outcome
|
|
17
|
+
* being recorded, the key stays `pending`, and retries return
|
|
18
|
+
* `PAYMENT_STATE_UNKNOWN` rather than risking a second payment. The owner
|
|
19
|
+
* reconciles that window against the payment ledger.
|
|
20
|
+
*/
|
|
21
|
+
import type { EdgeOperationResult } from '@totemsdk/edge';
|
|
22
|
+
import { type CasStore, type StorageAdapterWithCapabilities, type WriteAckMode } from '@totemsdk/storage';
|
|
23
|
+
export interface PaymentResult {
|
|
24
|
+
txpowId?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface PaymentPortLike {
|
|
27
|
+
pay(params: {
|
|
28
|
+
recipient: string;
|
|
29
|
+
amount: string;
|
|
30
|
+
tokenId?: string;
|
|
31
|
+
memo?: string;
|
|
32
|
+
idempotencyKey?: string;
|
|
33
|
+
}): Promise<EdgeOperationResult<PaymentResult>>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Idempotency claim record. `pending` is written atomically before the port
|
|
37
|
+
* call; `completed` records the outcome. A `pending` key is never re-claimed
|
|
38
|
+
* automatically — that is the double-pay guard.
|
|
39
|
+
*/
|
|
40
|
+
export type PurchasePaymentClaimRecord = {
|
|
41
|
+
phase: 'pending';
|
|
42
|
+
claimedAt: number;
|
|
43
|
+
} | {
|
|
44
|
+
phase: 'completed';
|
|
45
|
+
result: EdgeOperationResult<PaymentResult>;
|
|
46
|
+
completedAt: number;
|
|
47
|
+
};
|
|
48
|
+
/** The durable idempotency store must support conditional (CAS) writes. */
|
|
49
|
+
export type PurchasePaymentStore = StorageAdapterWithCapabilities & CasStore;
|
|
50
|
+
export interface PurchasePaymentAdapterConfig {
|
|
51
|
+
/** The underlying payment port (L1/L2/hosted). */
|
|
52
|
+
port: PaymentPortLike;
|
|
53
|
+
/**
|
|
54
|
+
* Optional claim store. When omitted, a dev in-memory store is used (no
|
|
55
|
+
* crash guarantees and no cross-instance dedup). A durable CAS-capable
|
|
56
|
+
* store (e.g. a `@totemsdk/storage` FileStore/SqliteStore) makes retries
|
|
57
|
+
* safe across restarts and processes. No silent downgrade: a supplied
|
|
58
|
+
* store is asserted CAS-capable and `durably-acknowledged` unless
|
|
59
|
+
* `requireAckMode` opts into a weaker acknowledgment.
|
|
60
|
+
*/
|
|
61
|
+
store?: PurchasePaymentStore;
|
|
62
|
+
/** Key namespace prefix; default `totem_payment:v1:`. */
|
|
63
|
+
namespace?: string;
|
|
64
|
+
/** Required write acknowledgment for a supplied store; default `durably-acknowledged`. */
|
|
65
|
+
requireAckMode?: WriteAckMode;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Create a PurchasePaymentPort with an atomic idempotency-key claim.
|
|
69
|
+
*/
|
|
70
|
+
export declare function createPurchasePaymentAdapter(config: PurchasePaymentAdapterConfig): {
|
|
71
|
+
pay(params: {
|
|
72
|
+
recipient: string;
|
|
73
|
+
amount: string;
|
|
74
|
+
tokenId?: string;
|
|
75
|
+
memo?: string;
|
|
76
|
+
idempotencyKey?: string;
|
|
77
|
+
}): Promise<EdgeOperationResult<PaymentResult>>;
|
|
78
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* purchase-payment.ts — PurchasePaymentPort adapter with an atomic claim.
|
|
4
|
+
*
|
|
5
|
+
* Wraps any EdgePaymentPort (L1 Minima, L2 Omnia, hosted) and adds a stable
|
|
6
|
+
* idempotency-key claim so a retry after a lost response never double-pays.
|
|
7
|
+
*
|
|
8
|
+
* The idempotency key is the stable purchase operation identity
|
|
9
|
+
* (`purchaseId:agreementId:payment`) supplied by the purchasing layer. The
|
|
10
|
+
* wrapper records an atomic pending marker (claim) *before* it invokes the
|
|
11
|
+
* underlying payment port; only the caller that wins the claim pays. A retry
|
|
12
|
+
* with the same key returns the prior result without re-invoking the port, and
|
|
13
|
+
* a concurrent duplicate never double-pays — it either replays the completed
|
|
14
|
+
* outcome or surfaces `PAYMENT_STATE_UNKNOWN` for an in-flight claim.
|
|
15
|
+
*
|
|
16
|
+
* Final exactly-once still depends on the payment system's own reconciliation:
|
|
17
|
+
* if the process dies between the port accepting the payment and the outcome
|
|
18
|
+
* being recorded, the key stays `pending`, and retries return
|
|
19
|
+
* `PAYMENT_STATE_UNKNOWN` rather than risking a second payment. The owner
|
|
20
|
+
* reconciles that window against the payment ledger.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.createPurchasePaymentAdapter = createPurchasePaymentAdapter;
|
|
24
|
+
const storage_1 = require("@totemsdk/storage");
|
|
25
|
+
const DEFAULT_NAMESPACE = 'totem_payment:v1:';
|
|
26
|
+
/** In-process serialization so concurrent claims never double-pay on one store. */
|
|
27
|
+
const locks = new WeakMap();
|
|
28
|
+
function withPaymentLock(store, fn) {
|
|
29
|
+
const tail = locks.get(store);
|
|
30
|
+
const next = tail ? tail.then(fn, fn) : fn();
|
|
31
|
+
const settled = next.catch(() => undefined);
|
|
32
|
+
locks.set(store, settled);
|
|
33
|
+
return next;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Create a PurchasePaymentPort with an atomic idempotency-key claim.
|
|
37
|
+
*/
|
|
38
|
+
function createPurchasePaymentAdapter(config) {
|
|
39
|
+
const { port } = config;
|
|
40
|
+
const namespace = config.namespace ?? DEFAULT_NAMESPACE;
|
|
41
|
+
const store = config.store ??
|
|
42
|
+
new storage_1.MemoryStore(); // dev mode — volatile, process-local
|
|
43
|
+
if (config.store) {
|
|
44
|
+
// No silent downgrade: a consumer-supplied store must be able to claim
|
|
45
|
+
// (CAS) and acknowledge durable writes unless explicitly opted out.
|
|
46
|
+
(0, storage_1.assertCapabilities)(store, {
|
|
47
|
+
conditional: true,
|
|
48
|
+
acknowledge: config.requireAckMode ?? 'durably-acknowledged',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const keyFor = (idempotencyKey) => namespace + idempotencyKey;
|
|
52
|
+
return {
|
|
53
|
+
async pay(params) {
|
|
54
|
+
const key = params.idempotencyKey;
|
|
55
|
+
if (!key) {
|
|
56
|
+
// No idempotency possible without a stable key — pass straight through.
|
|
57
|
+
return port.pay(params);
|
|
58
|
+
}
|
|
59
|
+
// The whole claim→pay→complete path is serialized per store so that N
|
|
60
|
+
// concurrent same-key callers resolve consistently: the winner pays, and
|
|
61
|
+
// every loser replays the completed outcome rather than racing the
|
|
62
|
+
// in-flight window.
|
|
63
|
+
return withPaymentLock(store, async () => {
|
|
64
|
+
const claimKey = keyFor(key);
|
|
65
|
+
// Atomic claim: exactly one caller wins `pending`; concurrent duplicates
|
|
66
|
+
// either replay a completed outcome or get PAYMENT_STATE_UNKNOWN.
|
|
67
|
+
const claim = await store.conditionalUpdate(claimKey, (current) => {
|
|
68
|
+
if (current === null || current === undefined) {
|
|
69
|
+
return { next: { phase: 'pending', claimedAt: Date.now() } };
|
|
70
|
+
}
|
|
71
|
+
return { abort: 'already-claimed' };
|
|
72
|
+
});
|
|
73
|
+
if (!claim.applied) {
|
|
74
|
+
const existing = await store.get(claimKey);
|
|
75
|
+
if (existing && existing.phase === 'completed') {
|
|
76
|
+
return existing.result;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
errorCode: 'PAYMENT_STATE_UNKNOWN',
|
|
81
|
+
error: `payment for idempotency key '${key}' is already in flight`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
let result;
|
|
85
|
+
try {
|
|
86
|
+
result = await port.pay(params);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
result = {
|
|
90
|
+
ok: false,
|
|
91
|
+
errorCode: 'PAYMENT_STATE_UNKNOWN',
|
|
92
|
+
error: `payment port threw: ${err.message}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// Record the outcome only if we still own the pending claim.
|
|
96
|
+
await store.conditionalUpdate(claimKey, (current) => {
|
|
97
|
+
if (current !== null && current !== undefined && current.phase === 'pending') {
|
|
98
|
+
return {
|
|
99
|
+
next: { phase: 'completed', result, completedAt: Date.now() },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return { abort: 'gone' };
|
|
103
|
+
});
|
|
104
|
+
return result;
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sqlite-commerce-store.ts — Production SQLite-backed CommerceStore.
|
|
3
|
+
*
|
|
4
|
+
* Implements the five durable commerce contracts over ONE SQLite database:
|
|
5
|
+
* NegotiationStore, PurchaseStore, ReplayLedger, PrincipalNegotiationStore,
|
|
6
|
+
* OutboxStore.
|
|
7
|
+
*
|
|
8
|
+
* The database connection, journal/busy pragmas, transaction lifecycle, and
|
|
9
|
+
* CAS kinematics are owned by the shared `SqliteStore` primitive from
|
|
10
|
+
* `@totemsdk/storage/sqlite` (RFC-007 §4.2). All consumer mutations run through
|
|
11
|
+
* `transactionalSync` (atomic multi-statement batches) or the generalized
|
|
12
|
+
* `transitionAndEnqueue` (revision-CAS update + outbox-style enqueue in one
|
|
13
|
+
* commit). Table schemas and row shapes are unchanged ("records unchanged in
|
|
14
|
+
* shape").
|
|
15
|
+
*
|
|
16
|
+
* The critical guarantee — `transitionAndEnqueue` (negotiation CAS + outbox
|
|
17
|
+
* enqueue) — maps to ONE transaction on the shared primitive:
|
|
18
|
+
*
|
|
19
|
+
* BEGIN
|
|
20
|
+
* UPDATE negotiations SET ... WHERE negotiation_id=? AND revision=?
|
|
21
|
+
* INSERT INTO outbox (...) VALUES (...)
|
|
22
|
+
* COMMIT (or ROLLBACK on CAS miss — nothing is enqueued)
|
|
23
|
+
*
|
|
24
|
+
* Uses WAL mode + busy_timeout for concurrent runtime use. Does NOT persist
|
|
25
|
+
* private keys — only stable references/IDs.
|
|
26
|
+
*
|
|
27
|
+
* All statements use positional `?` parameters so the same SQL runs on the
|
|
28
|
+
* real better-sqlite3 native binding and the node:sqlite jest mock.
|
|
29
|
+
*/
|
|
30
|
+
import type { NegotiationStore, PrincipalNegotiationStore, PurchaseStore } from '@totemsdk/edge';
|
|
31
|
+
import type { ReplayLedger } from '@totemsdk/edge';
|
|
32
|
+
import type { OutboxStore } from '@totemsdk/edge';
|
|
33
|
+
/** Aggregated durable commerce store. */
|
|
34
|
+
export interface CommerceStore {
|
|
35
|
+
negotiations: NegotiationStore;
|
|
36
|
+
purchases: PurchaseStore;
|
|
37
|
+
replay: ReplayLedger;
|
|
38
|
+
principals: PrincipalNegotiationStore;
|
|
39
|
+
outbox: OutboxStore;
|
|
40
|
+
}
|
|
41
|
+
export interface SQLiteCommerceStoreConfig {
|
|
42
|
+
/** File path, or ':memory:' for ephemeral. */
|
|
43
|
+
filename: string;
|
|
44
|
+
/** WAL mode (default true). */
|
|
45
|
+
wal?: boolean;
|
|
46
|
+
/** Busy timeout ms (default 5000). */
|
|
47
|
+
busyTimeoutMs?: number;
|
|
48
|
+
}
|
|
49
|
+
export declare class SQLiteCommerceStore implements CommerceStore {
|
|
50
|
+
readonly negotiations: NegotiationStore;
|
|
51
|
+
readonly purchases: PurchaseStore;
|
|
52
|
+
readonly replay: ReplayLedger;
|
|
53
|
+
readonly principals: PrincipalNegotiationStore;
|
|
54
|
+
readonly outbox: OutboxStore;
|
|
55
|
+
private readonly store;
|
|
56
|
+
constructor(config: SQLiteCommerceStoreConfig);
|
|
57
|
+
close(): void;
|
|
58
|
+
private createNegotiationStore;
|
|
59
|
+
private createPurchaseStore;
|
|
60
|
+
private createReplayLedger;
|
|
61
|
+
private createPrincipalStore;
|
|
62
|
+
private createOutboxStore;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Create a SQLite-backed CommerceStore.
|
|
66
|
+
*
|
|
67
|
+
* @param config filename (or ':memory:') + WAL/busy options.
|
|
68
|
+
*/
|
|
69
|
+
export declare function createSQLiteCommerceStore(config: SQLiteCommerceStoreConfig): SQLiteCommerceStore;
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* sqlite-commerce-store.ts — Production SQLite-backed CommerceStore.
|
|
4
|
+
*
|
|
5
|
+
* Implements the five durable commerce contracts over ONE SQLite database:
|
|
6
|
+
* NegotiationStore, PurchaseStore, ReplayLedger, PrincipalNegotiationStore,
|
|
7
|
+
* OutboxStore.
|
|
8
|
+
*
|
|
9
|
+
* The database connection, journal/busy pragmas, transaction lifecycle, and
|
|
10
|
+
* CAS kinematics are owned by the shared `SqliteStore` primitive from
|
|
11
|
+
* `@totemsdk/storage/sqlite` (RFC-007 §4.2). All consumer mutations run through
|
|
12
|
+
* `transactionalSync` (atomic multi-statement batches) or the generalized
|
|
13
|
+
* `transitionAndEnqueue` (revision-CAS update + outbox-style enqueue in one
|
|
14
|
+
* commit). Table schemas and row shapes are unchanged ("records unchanged in
|
|
15
|
+
* shape").
|
|
16
|
+
*
|
|
17
|
+
* The critical guarantee — `transitionAndEnqueue` (negotiation CAS + outbox
|
|
18
|
+
* enqueue) — maps to ONE transaction on the shared primitive:
|
|
19
|
+
*
|
|
20
|
+
* BEGIN
|
|
21
|
+
* UPDATE negotiations SET ... WHERE negotiation_id=? AND revision=?
|
|
22
|
+
* INSERT INTO outbox (...) VALUES (...)
|
|
23
|
+
* COMMIT (or ROLLBACK on CAS miss — nothing is enqueued)
|
|
24
|
+
*
|
|
25
|
+
* Uses WAL mode + busy_timeout for concurrent runtime use. Does NOT persist
|
|
26
|
+
* private keys — only stable references/IDs.
|
|
27
|
+
*
|
|
28
|
+
* All statements use positional `?` parameters so the same SQL runs on the
|
|
29
|
+
* real better-sqlite3 native binding and the node:sqlite jest mock.
|
|
30
|
+
*/
|
|
31
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
+
exports.SQLiteCommerceStore = void 0;
|
|
33
|
+
exports.createSQLiteCommerceStore = createSQLiteCommerceStore;
|
|
34
|
+
const sqlite_1 = require("@totemsdk/storage/sqlite");
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
// Serialization helpers
|
|
37
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
function toJson(value) {
|
|
39
|
+
return JSON.stringify(value, (_key, v) => (typeof v === 'bigint' ? v.toString() : v));
|
|
40
|
+
}
|
|
41
|
+
function fromJson(json) {
|
|
42
|
+
return JSON.parse(json);
|
|
43
|
+
}
|
|
44
|
+
/** Reconstruct a NegotiationRecord, restoring bigint fields. */
|
|
45
|
+
function rowToNegotiation(row) {
|
|
46
|
+
const record = fromJson(row.record_json);
|
|
47
|
+
return {
|
|
48
|
+
...record,
|
|
49
|
+
cumulativeWork: BigInt(String(record.cumulativeWork ?? '0')),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function negotiationToRow(record) {
|
|
53
|
+
return {
|
|
54
|
+
negotiation_id: record.negotiationId,
|
|
55
|
+
record_json: toJson(record),
|
|
56
|
+
revision: record.revision,
|
|
57
|
+
state: record.state,
|
|
58
|
+
principal: record.principal,
|
|
59
|
+
updated_at: record.updatedAt,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function rowToPurchase(row) {
|
|
63
|
+
return fromJson(row.record_json);
|
|
64
|
+
}
|
|
65
|
+
function purchaseToRow(record) {
|
|
66
|
+
return {
|
|
67
|
+
purchase_id: record.purchaseId,
|
|
68
|
+
record_json: toJson(record),
|
|
69
|
+
revision: record.revision,
|
|
70
|
+
status: record.status,
|
|
71
|
+
updated_at: record.updatedAt,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function rowToReplay(row) {
|
|
75
|
+
if (row.state === 'COMPLETED') {
|
|
76
|
+
return {
|
|
77
|
+
state: 'COMPLETED',
|
|
78
|
+
outcome: fromJson(row.outcome_json ?? '{}'),
|
|
79
|
+
completedAt: row.completed_at ?? 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
state: 'PROCESSING',
|
|
84
|
+
claimedAt: row.claimed_at,
|
|
85
|
+
...(row.lease_until !== null ? { leaseUntil: row.lease_until } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function rowToOutbox(row) {
|
|
89
|
+
return {
|
|
90
|
+
messageId: row.message_id,
|
|
91
|
+
recipient: row.recipient,
|
|
92
|
+
message: fromJson(row.payload),
|
|
93
|
+
enqueuedAt: row.enqueued_at,
|
|
94
|
+
...(row.delivered_at !== null ? { deliveredAt: row.delivered_at } : {}),
|
|
95
|
+
attempts: row.attempts,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
99
|
+
// Schema (unchanged — "records unchanged in shape")
|
|
100
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
101
|
+
const SCHEMA = `
|
|
102
|
+
CREATE TABLE IF NOT EXISTS negotiations (
|
|
103
|
+
negotiation_id TEXT PRIMARY KEY,
|
|
104
|
+
record_json TEXT NOT NULL,
|
|
105
|
+
revision INTEGER NOT NULL,
|
|
106
|
+
state TEXT NOT NULL,
|
|
107
|
+
principal TEXT NOT NULL,
|
|
108
|
+
updated_at INTEGER NOT NULL
|
|
109
|
+
);
|
|
110
|
+
CREATE INDEX IF NOT EXISTS idx_negotiations_principal ON negotiations(principal);
|
|
111
|
+
CREATE INDEX IF NOT EXISTS idx_negotiations_state ON negotiations(state);
|
|
112
|
+
|
|
113
|
+
CREATE TABLE IF NOT EXISTS purchases (
|
|
114
|
+
purchase_id TEXT PRIMARY KEY,
|
|
115
|
+
record_json TEXT NOT NULL,
|
|
116
|
+
revision INTEGER NOT NULL,
|
|
117
|
+
status TEXT NOT NULL,
|
|
118
|
+
updated_at INTEGER NOT NULL
|
|
119
|
+
);
|
|
120
|
+
CREATE INDEX IF NOT EXISTS idx_purchases_status ON purchases(status);
|
|
121
|
+
|
|
122
|
+
CREATE TABLE IF NOT EXISTS replay (
|
|
123
|
+
message_id TEXT PRIMARY KEY,
|
|
124
|
+
state TEXT NOT NULL,
|
|
125
|
+
claimed_at INTEGER NOT NULL,
|
|
126
|
+
lease_until INTEGER,
|
|
127
|
+
outcome_json TEXT,
|
|
128
|
+
completed_at INTEGER
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
CREATE TABLE IF NOT EXISTS principal_slots (
|
|
132
|
+
principal TEXT NOT NULL,
|
|
133
|
+
negotiation_id TEXT NOT NULL,
|
|
134
|
+
opened_at INTEGER NOT NULL,
|
|
135
|
+
PRIMARY KEY (principal, negotiation_id)
|
|
136
|
+
);
|
|
137
|
+
CREATE INDEX IF NOT EXISTS idx_principal_slots_principal ON principal_slots(principal);
|
|
138
|
+
|
|
139
|
+
CREATE TABLE IF NOT EXISTS principal_cooldown (
|
|
140
|
+
principal TEXT PRIMARY KEY,
|
|
141
|
+
cooldown_until INTEGER NOT NULL
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
CREATE TABLE IF NOT EXISTS outbox (
|
|
145
|
+
message_id TEXT PRIMARY KEY,
|
|
146
|
+
recipient TEXT NOT NULL,
|
|
147
|
+
payload TEXT NOT NULL,
|
|
148
|
+
enqueued_at INTEGER NOT NULL,
|
|
149
|
+
delivered_at INTEGER,
|
|
150
|
+
attempts INTEGER NOT NULL DEFAULT 0
|
|
151
|
+
);
|
|
152
|
+
CREATE INDEX IF NOT EXISTS idx_outbox_undelivered ON outbox(delivered_at);
|
|
153
|
+
`;
|
|
154
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
155
|
+
// SQLite CommerceStore
|
|
156
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
157
|
+
class SQLiteCommerceStore {
|
|
158
|
+
constructor(config) {
|
|
159
|
+
this.store = new sqlite_1.SqliteStore(config.filename, {
|
|
160
|
+
createKvTable: false,
|
|
161
|
+
wal: config.wal,
|
|
162
|
+
busyTimeoutMs: config.busyTimeoutMs ?? 5000,
|
|
163
|
+
});
|
|
164
|
+
this.store.transactionalSync((tx) => tx.exec(SCHEMA));
|
|
165
|
+
this.negotiations = this.createNegotiationStore();
|
|
166
|
+
this.purchases = this.createPurchaseStore();
|
|
167
|
+
this.replay = this.createReplayLedger();
|
|
168
|
+
this.principals = this.createPrincipalStore();
|
|
169
|
+
this.outbox = this.createOutboxStore();
|
|
170
|
+
}
|
|
171
|
+
close() {
|
|
172
|
+
void this.store.close();
|
|
173
|
+
}
|
|
174
|
+
// ── NegotiationStore ──────────────────────────────────────────────────────
|
|
175
|
+
createNegotiationStore() {
|
|
176
|
+
const store = this.store;
|
|
177
|
+
const INSERT_SQL = `
|
|
178
|
+
INSERT INTO negotiations (negotiation_id, record_json, revision, state, principal, updated_at)
|
|
179
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
180
|
+
`;
|
|
181
|
+
const GET_SQL = `SELECT * FROM negotiations WHERE negotiation_id = ?`;
|
|
182
|
+
const CAS_SQL = `
|
|
183
|
+
UPDATE negotiations
|
|
184
|
+
SET record_json = ?, revision = ?, state = ?, updated_at = ?
|
|
185
|
+
WHERE negotiation_id = ? AND revision = ?
|
|
186
|
+
`;
|
|
187
|
+
const LIST_RECOVERABLE_SQL = `
|
|
188
|
+
SELECT * FROM negotiations WHERE state NOT IN ('AGREED','REJECTED','CANCELLED','EXHAUSTED','EXPIRED')
|
|
189
|
+
`;
|
|
190
|
+
return {
|
|
191
|
+
async create(record) {
|
|
192
|
+
const row = negotiationToRow(record);
|
|
193
|
+
store.transactionalSync((tx) => {
|
|
194
|
+
tx.run(INSERT_SQL, row.negotiation_id, row.record_json, row.revision, row.state, row.principal, row.updated_at);
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
async get(negotiationId) {
|
|
198
|
+
const row = store.transactionalSync((tx) => tx.get(GET_SQL, negotiationId));
|
|
199
|
+
return row ? rowToNegotiation(row) : undefined;
|
|
200
|
+
},
|
|
201
|
+
async compareAndSet(negotiationId, expectedRevision, next) {
|
|
202
|
+
const row = negotiationToRow(next);
|
|
203
|
+
const touched = store.transactionalSync((tx) => tx.run(CAS_SQL, row.record_json, row.revision, row.state, row.updated_at, negotiationId, expectedRevision));
|
|
204
|
+
return touched === 1;
|
|
205
|
+
},
|
|
206
|
+
async transitionAndEnqueue(negotiationId, expectedRevision, next, outboxMessages) {
|
|
207
|
+
// Generalized transitionAndEnqueue: CAS the negotiation and enqueue
|
|
208
|
+
// every outbox message in ONE transaction (rollback on CAS miss).
|
|
209
|
+
const row = negotiationToRow(next);
|
|
210
|
+
const now = Date.now();
|
|
211
|
+
return store.transitionAndEnqueue({
|
|
212
|
+
casUpdateSql: CAS_SQL,
|
|
213
|
+
casUpdateParams: [
|
|
214
|
+
row.record_json, row.revision, row.state, row.updated_at,
|
|
215
|
+
negotiationId, expectedRevision,
|
|
216
|
+
],
|
|
217
|
+
enqueueSql: `
|
|
218
|
+
INSERT OR REPLACE INTO outbox (message_id, recipient, payload, enqueued_at, attempts)
|
|
219
|
+
VALUES (?, ?, ?, ?, 0)
|
|
220
|
+
`,
|
|
221
|
+
enqueueRows: outboxMessages.map((m) => [m.messageId, m.recipient, m.payload, now]),
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
async listRecoverable() {
|
|
225
|
+
const rows = store.transactionalSync((tx) => tx.all(LIST_RECOVERABLE_SQL));
|
|
226
|
+
return rows.map(rowToNegotiation);
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
// ── PurchaseStore ─────────────────────────────────────────────────────────
|
|
231
|
+
createPurchaseStore() {
|
|
232
|
+
const store = this.store;
|
|
233
|
+
const INSERT_SQL = `
|
|
234
|
+
INSERT INTO purchases (purchase_id, record_json, revision, status, updated_at)
|
|
235
|
+
VALUES (?, ?, ?, ?, ?)
|
|
236
|
+
`;
|
|
237
|
+
const GET_SQL = `SELECT * FROM purchases WHERE purchase_id = ?`;
|
|
238
|
+
const CAS_SQL = `
|
|
239
|
+
UPDATE purchases
|
|
240
|
+
SET record_json = ?, revision = ?, status = ?, updated_at = ?
|
|
241
|
+
WHERE purchase_id = ? AND revision = ?
|
|
242
|
+
`;
|
|
243
|
+
const LIST_RECOVERABLE_SQL = `
|
|
244
|
+
SELECT * FROM purchases WHERE status NOT IN ('COMPLETED','FAILED','CANCELLED')
|
|
245
|
+
`;
|
|
246
|
+
return {
|
|
247
|
+
async create(record) {
|
|
248
|
+
const row = purchaseToRow(record);
|
|
249
|
+
store.transactionalSync((tx) => {
|
|
250
|
+
tx.run(INSERT_SQL, row.purchase_id, row.record_json, row.revision, row.status, row.updated_at);
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
async get(purchaseId) {
|
|
254
|
+
const row = store.transactionalSync((tx) => tx.get(GET_SQL, purchaseId));
|
|
255
|
+
return row ? rowToPurchase(row) : undefined;
|
|
256
|
+
},
|
|
257
|
+
async compareAndSet(purchaseId, expectedRevision, next) {
|
|
258
|
+
const row = purchaseToRow(next);
|
|
259
|
+
const touched = store.transactionalSync((tx) => tx.run(CAS_SQL, row.record_json, row.revision, row.status, row.updated_at, purchaseId, expectedRevision));
|
|
260
|
+
return touched === 1;
|
|
261
|
+
},
|
|
262
|
+
async listRecoverable() {
|
|
263
|
+
const rows = store.transactionalSync((tx) => tx.all(LIST_RECOVERABLE_SQL));
|
|
264
|
+
return rows.map(rowToPurchase);
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
// ── ReplayLedger ──────────────────────────────────────────────────────────
|
|
269
|
+
createReplayLedger() {
|
|
270
|
+
const store = this.store;
|
|
271
|
+
const GET_SQL = `SELECT * FROM replay WHERE message_id = ?`;
|
|
272
|
+
const INSERT_PROCESSING_SQL = `
|
|
273
|
+
INSERT INTO replay (message_id, state, claimed_at, lease_until)
|
|
274
|
+
VALUES (?, 'PROCESSING', ?, ?)
|
|
275
|
+
`;
|
|
276
|
+
const UPDATE_PROCESSING_SQL = `
|
|
277
|
+
UPDATE replay SET claimed_at = ?, lease_until = ? WHERE message_id = ? AND state = 'PROCESSING'
|
|
278
|
+
`;
|
|
279
|
+
const COMPLETE_SQL = `
|
|
280
|
+
UPDATE replay SET state = 'COMPLETED', outcome_json = ?, completed_at = ?
|
|
281
|
+
WHERE message_id = ?
|
|
282
|
+
`;
|
|
283
|
+
return {
|
|
284
|
+
async claim(messageId, receivedAt, leaseMs = 30000) {
|
|
285
|
+
return store.transactionalSync((tx) => {
|
|
286
|
+
const row = tx.get(GET_SQL, messageId);
|
|
287
|
+
if (!row) {
|
|
288
|
+
tx.run(INSERT_PROCESSING_SQL, messageId, receivedAt, receivedAt + leaseMs);
|
|
289
|
+
return { claimed: true };
|
|
290
|
+
}
|
|
291
|
+
if (row.state === 'COMPLETED') {
|
|
292
|
+
return { claimed: false, entry: rowToReplay(row) };
|
|
293
|
+
}
|
|
294
|
+
// PROCESSING — reclaim if lease expired.
|
|
295
|
+
if (row.lease_until !== null && receivedAt > row.lease_until) {
|
|
296
|
+
tx.run(UPDATE_PROCESSING_SQL, receivedAt, receivedAt + leaseMs, messageId);
|
|
297
|
+
return { claimed: true, reclaimed: true };
|
|
298
|
+
}
|
|
299
|
+
return { claimed: false, entry: rowToReplay(row) };
|
|
300
|
+
});
|
|
301
|
+
},
|
|
302
|
+
async complete(messageId, outcome) {
|
|
303
|
+
store.transactionalSync((tx) => {
|
|
304
|
+
tx.run(COMPLETE_SQL, toJson(outcome), Date.now(), messageId);
|
|
305
|
+
});
|
|
306
|
+
},
|
|
307
|
+
async get(messageId) {
|
|
308
|
+
const row = store.transactionalSync((tx) => tx.get(GET_SQL, messageId));
|
|
309
|
+
return row ? rowToReplay(row) : undefined;
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
// ── PrincipalNegotiationStore ────────────────────────────────────────────
|
|
314
|
+
createPrincipalStore() {
|
|
315
|
+
const store = this.store;
|
|
316
|
+
const INSERT_SLOT_SQL = `
|
|
317
|
+
INSERT INTO principal_slots (principal, negotiation_id, opened_at) VALUES (?, ?, ?)
|
|
318
|
+
`;
|
|
319
|
+
const DELETE_SLOT_SQL = `
|
|
320
|
+
DELETE FROM principal_slots WHERE principal = ? AND negotiation_id = ?
|
|
321
|
+
`;
|
|
322
|
+
const SLOTS_FOR_SQL = `SELECT * FROM principal_slots WHERE principal = ?`;
|
|
323
|
+
const GET_COOLDOWN_SQL = `SELECT cooldown_until FROM principal_cooldown WHERE principal = ?`;
|
|
324
|
+
const SET_COOLDOWN_SQL = `
|
|
325
|
+
INSERT OR REPLACE INTO principal_cooldown (principal, cooldown_until) VALUES (?, ?)
|
|
326
|
+
`;
|
|
327
|
+
return {
|
|
328
|
+
async tryOpen(principal, negotiationId, now, limits) {
|
|
329
|
+
// Atomic on the shared transaction primitive: check cooldown, window,
|
|
330
|
+
// concurrency, then consume — two concurrent opens cannot both pass.
|
|
331
|
+
return store.transactionalSync((tx) => {
|
|
332
|
+
const cooldownRow = tx.get(GET_COOLDOWN_SQL, principal);
|
|
333
|
+
if (cooldownRow && now < cooldownRow.cooldown_until) {
|
|
334
|
+
return { allowed: false, reason: 'COOLDOWN' };
|
|
335
|
+
}
|
|
336
|
+
const slots = tx.all(SLOTS_FOR_SQL, principal);
|
|
337
|
+
const recent = slots.filter((s) => now - s.opened_at < limits.windowMs);
|
|
338
|
+
if (recent.length >= limits.maxNegotiationsPerWindow) {
|
|
339
|
+
return { allowed: false, reason: 'WINDOW_LIMIT' };
|
|
340
|
+
}
|
|
341
|
+
if (slots.length >= limits.maxConcurrentNegotiations) {
|
|
342
|
+
return { allowed: false, reason: 'CONCURRENCY_LIMIT' };
|
|
343
|
+
}
|
|
344
|
+
tx.run(INSERT_SLOT_SQL, principal, negotiationId, now);
|
|
345
|
+
return { allowed: true };
|
|
346
|
+
});
|
|
347
|
+
},
|
|
348
|
+
async close(principal, negotiationId) {
|
|
349
|
+
store.transactionalSync((tx) => {
|
|
350
|
+
tx.run(DELETE_SLOT_SQL, principal, negotiationId);
|
|
351
|
+
});
|
|
352
|
+
},
|
|
353
|
+
async reconcile(principal, activeNegotiationIds) {
|
|
354
|
+
store.transactionalSync((tx) => {
|
|
355
|
+
const slots = tx.all(SLOTS_FOR_SQL, principal);
|
|
356
|
+
const active = new Set(activeNegotiationIds);
|
|
357
|
+
for (const slot of slots) {
|
|
358
|
+
if (!active.has(slot.negotiation_id)) {
|
|
359
|
+
tx.run(DELETE_SLOT_SQL, principal, slot.negotiation_id);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
},
|
|
364
|
+
async getCooldownUntil(principal) {
|
|
365
|
+
const row = store.transactionalSync((tx) => tx.get(GET_COOLDOWN_SQL, principal));
|
|
366
|
+
return row?.cooldown_until ?? 0;
|
|
367
|
+
},
|
|
368
|
+
async setCooldownUntil(principal, until) {
|
|
369
|
+
store.transactionalSync((tx) => {
|
|
370
|
+
tx.run(SET_COOLDOWN_SQL, principal, until);
|
|
371
|
+
});
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
// ── OutboxStore ───────────────────────────────────────────────────────────
|
|
376
|
+
createOutboxStore() {
|
|
377
|
+
const store = this.store;
|
|
378
|
+
const ENQUEUE_SQL = `
|
|
379
|
+
INSERT OR REPLACE INTO outbox (message_id, recipient, payload, enqueued_at, attempts)
|
|
380
|
+
VALUES (?, ?, ?, ?, ?)
|
|
381
|
+
`;
|
|
382
|
+
const LIST_UNDELIVERED_SQL = `SELECT * FROM outbox WHERE delivered_at IS NULL`;
|
|
383
|
+
const MARK_DELIVERED_SQL = `UPDATE outbox SET delivered_at = ? WHERE message_id = ?`;
|
|
384
|
+
const RECORD_ATTEMPT_SQL = `UPDATE outbox SET attempts = attempts + 1 WHERE message_id = ?`;
|
|
385
|
+
return {
|
|
386
|
+
async enqueue(entry) {
|
|
387
|
+
store.transactionalSync((tx) => {
|
|
388
|
+
tx.run(ENQUEUE_SQL, entry.messageId, entry.recipient, toJson(entry.message), entry.enqueuedAt, entry.attempts);
|
|
389
|
+
});
|
|
390
|
+
},
|
|
391
|
+
async listUndelivered() {
|
|
392
|
+
const rows = store.transactionalSync((tx) => tx.all(LIST_UNDELIVERED_SQL));
|
|
393
|
+
return rows.map(rowToOutbox);
|
|
394
|
+
},
|
|
395
|
+
async markDelivered(messageId, deliveredAt) {
|
|
396
|
+
store.transactionalSync((tx) => {
|
|
397
|
+
tx.run(MARK_DELIVERED_SQL, deliveredAt, messageId);
|
|
398
|
+
});
|
|
399
|
+
},
|
|
400
|
+
async recordAttempt(messageId) {
|
|
401
|
+
store.transactionalSync((tx) => {
|
|
402
|
+
tx.run(RECORD_ATTEMPT_SQL, messageId);
|
|
403
|
+
});
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
exports.SQLiteCommerceStore = SQLiteCommerceStore;
|
|
409
|
+
/**
|
|
410
|
+
* Create a SQLite-backed CommerceStore.
|
|
411
|
+
*
|
|
412
|
+
* @param config filename (or ':memory:') + WAL/busy options.
|
|
413
|
+
*/
|
|
414
|
+
function createSQLiteCommerceStore(config) {
|
|
415
|
+
return new SQLiteCommerceStore(config);
|
|
416
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@totemsdk/edge-adapters",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Reference adapters bridging Totem SDK packages to @totemsdk/edge port interfaces",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -16,17 +16,20 @@
|
|
|
16
16
|
"README.md",
|
|
17
17
|
"LICENSE"
|
|
18
18
|
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@totemsdk/storage": "^0.1.0"
|
|
21
|
+
},
|
|
19
22
|
"peerDependencies": {
|
|
20
|
-
"@totemsdk/agent-policy": "^0.
|
|
21
|
-
"@totemsdk/
|
|
22
|
-
"@totemsdk/chain-provider": "^0.2.0",
|
|
23
|
-
"@totemsdk/location-proof": "^1.0.0",
|
|
23
|
+
"@totemsdk/agent-policy": "^0.3.1",
|
|
24
|
+
"@totemsdk/chain-provider": "^0.2.1",
|
|
24
25
|
"@totemsdk/lookup-client": "^0.2.0",
|
|
26
|
+
"@totemsdk/identity": "^1.0.1",
|
|
27
|
+
"@totemsdk/location-proof": "^1.0.0",
|
|
25
28
|
"@totemsdk/manifest": "^1.0.0",
|
|
26
|
-
"@totemsdk/omnia-router": "^0.2.
|
|
29
|
+
"@totemsdk/omnia-router": "^0.2.1",
|
|
27
30
|
"@totemsdk/proof": "^1.0.0",
|
|
28
31
|
"@totemsdk/pubsub-transport": "^0.2.0",
|
|
29
|
-
"@totemsdk/root-identity": "^1.0.
|
|
32
|
+
"@totemsdk/root-identity": "^1.0.9",
|
|
30
33
|
"@totemsdk/stream-transport": "^0.2.0"
|
|
31
34
|
},
|
|
32
35
|
"peerDependenciesMeta": {
|
|
@@ -70,18 +73,20 @@
|
|
|
70
73
|
"jest": "^30.4.2",
|
|
71
74
|
"ts-jest": "^29.0.0",
|
|
72
75
|
"typescript": "^7.0.2",
|
|
73
|
-
"@totemsdk/
|
|
74
|
-
"@totemsdk/
|
|
76
|
+
"@totemsdk/agent-policy": "^0.3.1",
|
|
77
|
+
"@totemsdk/chain-provider": "^0.2.1",
|
|
78
|
+
"@totemsdk/core": "^1.2.11",
|
|
79
|
+
"@totemsdk/edge": "^1.2.0",
|
|
80
|
+
"@totemsdk/identity": "^1.0.1",
|
|
75
81
|
"@totemsdk/lookup-client": "^0.2.0",
|
|
76
|
-
"@totemsdk/identity": "^1.0.0",
|
|
77
|
-
"@totemsdk/agent-policy": "^0.2.1",
|
|
78
|
-
"@totemsdk/location-proof": "^1.0.0",
|
|
79
82
|
"@totemsdk/manifest": "^1.0.0",
|
|
80
|
-
"@totemsdk/omnia-router": "^0.2.
|
|
83
|
+
"@totemsdk/omnia-router": "^0.2.1",
|
|
81
84
|
"@totemsdk/pubsub-transport": "^0.2.0",
|
|
82
|
-
"@totemsdk/
|
|
85
|
+
"@totemsdk/location-proof": "^1.0.0",
|
|
86
|
+
"@totemsdk/root-identity": "^1.0.9",
|
|
83
87
|
"@totemsdk/proof": "^1.0.0",
|
|
84
|
-
"@totemsdk/
|
|
88
|
+
"@totemsdk/txpow": "^1.0.2",
|
|
89
|
+
"@totemsdk/stream-transport": "^0.2.0"
|
|
85
90
|
},
|
|
86
91
|
"engines": {
|
|
87
92
|
"node": ">=18.0.0"
|
|
@@ -91,7 +96,7 @@
|
|
|
91
96
|
},
|
|
92
97
|
"license": "MIT",
|
|
93
98
|
"author": "Totem SDK",
|
|
94
|
-
"homepage": "https://totem.ing",
|
|
99
|
+
"homepage": "https://docs.totem.ing",
|
|
95
100
|
"repository": {
|
|
96
101
|
"type": "git",
|
|
97
102
|
"url": "git+https://github.com/Totem-Edge/totem-sdk.git",
|