@peers-app/peers-sdk 0.23.11 → 0.23.13
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/data/change-tracking.d.ts +57 -2
- package/dist/data/change-tracking.js +110 -5
- package/dist/device/connection.js +2 -2
- package/dist/hlc.d.ts +65 -0
- package/dist/hlc.js +77 -0
- package/dist/hlc.test.d.ts +1 -0
- package/dist/hlc.test.js +45 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/package-loader/isolated-artifact.d.ts +44 -5
- package/dist/package-loader/isolated-artifact.js +66 -3
- package/dist/package-loader/isolated-artifact.test.js +51 -3
- package/dist/package-loader/isolated-runtime.d.ts +12 -0
- package/dist/package-loader/isolated-runtime.js +3 -1
- package/dist/package-loader/isolated-table-gateway.d.ts +1 -1
- package/dist/package-loader/isolated-table-gateway.js +54 -0
- package/dist/package-loader/isolated-table-gateway.test.js +61 -0
- package/dist/types/peer-device.d.ts +8 -0
- package/dist/types/peer-device.js +8 -0
- package/package.json +1 -1
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { Emitter, type Event } from "../events";
|
|
10
|
+
import { HybridLogicalClock } from "../hlc";
|
|
10
11
|
import { type DataFilter, type IDataQueryParams, type ISqlDb, type ITableMetaData, SQLDataSource } from "./orm";
|
|
12
|
+
/**
|
|
13
|
+
* Zod schema for a single granular change-log row in `ChangeTrackingV2`.
|
|
14
|
+
* `appliedAt` is assigned locally at insert time; incoming values are replaced.
|
|
15
|
+
*/
|
|
11
16
|
export declare const changeRecordSchema: z.ZodObject<{
|
|
12
17
|
changeId: z.ZodEffects<z.ZodString, string, string>;
|
|
13
18
|
transactionId: z.ZodOptional<z.ZodString>;
|
|
@@ -42,13 +47,57 @@ export declare const changeRecordSchema: z.ZodObject<{
|
|
|
42
47
|
transactionId?: string | undefined;
|
|
43
48
|
supersededAt?: number | undefined;
|
|
44
49
|
}>;
|
|
50
|
+
/**
|
|
51
|
+
* One JSON-patch operation (or root snapshot / delete) stored for synchronization.
|
|
52
|
+
*/
|
|
45
53
|
export type IChangeRecord = z.infer<typeof changeRecordSchema>;
|
|
54
|
+
/**
|
|
55
|
+
* Local-only change log used for peer sync.
|
|
56
|
+
*
|
|
57
|
+
* Owns two {@link HybridLogicalClock} instances for this database:
|
|
58
|
+
* - {@link clock} stamps `createdAt` / `supersededAt` (cross-device LWW)
|
|
59
|
+
* - `appliedClock` stamps `appliedAt` at insert time (per-device sync watermark)
|
|
60
|
+
*/
|
|
46
61
|
export declare class ChangeTrackingTable extends SQLDataSource<IChangeRecord> {
|
|
47
62
|
readonly dataChangedEmitter: Emitter<IChangeRecord>;
|
|
48
63
|
readonly dataChanged: Event<IChangeRecord>;
|
|
49
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Hybrid logical clock for author write times (`createdAt`, `supersededAt`).
|
|
66
|
+
* Observes accepted incoming `createdAt` values so later local writes order after them.
|
|
67
|
+
*/
|
|
68
|
+
readonly clock: HybridLogicalClock;
|
|
69
|
+
private readonly appliedClock;
|
|
70
|
+
private clockInit;
|
|
71
|
+
/**
|
|
72
|
+
* @param db - SQLite database that stores `ChangeTrackingV2`
|
|
73
|
+
* @param now - Optional physical-time source shared by both clocks (defaults to {@link getTimestamp})
|
|
74
|
+
*/
|
|
75
|
+
constructor({ db, now }: {
|
|
50
76
|
db: ISqlDb;
|
|
77
|
+
now?: () => number;
|
|
51
78
|
});
|
|
79
|
+
/**
|
|
80
|
+
* Ensures the table exists, seeds both clocks from durable history, and
|
|
81
|
+
* rewrites any far-future `createdAt` rows this device still owns.
|
|
82
|
+
*
|
|
83
|
+
* @param force - Re-run table migration and clock seeding
|
|
84
|
+
*/
|
|
85
|
+
initTable(force?: boolean): Promise<boolean>;
|
|
86
|
+
/**
|
|
87
|
+
* Drop the change-tracking table and clear memoized clock seeding so the next
|
|
88
|
+
* {@link initTable} reseeds from an empty (or newly created) table.
|
|
89
|
+
*/
|
|
90
|
+
dropTableIfExists(): Promise<void>;
|
|
91
|
+
private seedClocksAndRepair;
|
|
92
|
+
/**
|
|
93
|
+
* Rewrite local rows whose `createdAt` is beyond the change-timestamp bound.
|
|
94
|
+
* `createdAt` is restamped from {@link clock} (near wall time) and `appliedAt`
|
|
95
|
+
* from the unbounded apply clock so peers whose watermark already passed the
|
|
96
|
+
* old value will pull the repaired rows.
|
|
97
|
+
*
|
|
98
|
+
* @returns Number of rows rewritten
|
|
99
|
+
*/
|
|
100
|
+
repairFutureTimestamps(): Promise<number>;
|
|
52
101
|
/**
|
|
53
102
|
* Efficiently find which recordIds have been deleted.
|
|
54
103
|
*
|
|
@@ -79,6 +128,10 @@ export declare class ChangeTrackingTable extends SQLDataSource<IChangeRecord> {
|
|
|
79
128
|
* @returns Array of changeIds with no change records
|
|
80
129
|
*/
|
|
81
130
|
filterToMissingChangeIds(changeIds: string[]): Promise<string[]>;
|
|
131
|
+
/**
|
|
132
|
+
* Mark every still-active change for `recordId` whose `createdAt` is older
|
|
133
|
+
* than `supersededAt`. Used after a root snapshot, delete, or restore.
|
|
134
|
+
*/
|
|
82
135
|
markAllPriorChangesSuperseded(tableName: string, recordId: string, supersededAt: number): Promise<void>;
|
|
83
136
|
/**
|
|
84
137
|
* Batch update supersededAt timestamps for multiple changes.
|
|
@@ -99,7 +152,8 @@ export declare class ChangeTrackingTable extends SQLDataSource<IChangeRecord> {
|
|
|
99
152
|
*/
|
|
100
153
|
get(id: string): Promise<IChangeRecord | undefined>;
|
|
101
154
|
/**
|
|
102
|
-
*
|
|
155
|
+
* Insert a change row, assigning `appliedAt` from the local apply clock
|
|
156
|
+
* immediately before the SQL statement so watermarks stay monotonic.
|
|
103
157
|
*/
|
|
104
158
|
insert(record: IChangeRecord): Promise<IChangeRecord>;
|
|
105
159
|
/**
|
|
@@ -109,6 +163,7 @@ export declare class ChangeTrackingTable extends SQLDataSource<IChangeRecord> {
|
|
|
109
163
|
/**
|
|
110
164
|
* Synchronous insert for use within transactions.
|
|
111
165
|
* Table must be initialized before calling this method.
|
|
166
|
+
* Assigns `appliedAt` from the local apply clock immediately before the SQL statement.
|
|
112
167
|
*/
|
|
113
168
|
insertSync(record: IChangeRecord): IChangeRecord;
|
|
114
169
|
/**
|
|
@@ -10,10 +10,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
10
10
|
exports.ChangeTrackingTable = exports.changeRecordSchema = void 0;
|
|
11
11
|
const zod_1 = require("zod");
|
|
12
12
|
const events_1 = require("../events");
|
|
13
|
+
const hlc_1 = require("../hlc");
|
|
13
14
|
const serial_json_1 = require("../serial-json");
|
|
15
|
+
const peer_device_1 = require("../types/peer-device");
|
|
14
16
|
const zod_types_1 = require("../types/zod-types");
|
|
15
17
|
const utils_1 = require("../utils");
|
|
16
18
|
const orm_1 = require("./orm");
|
|
19
|
+
/**
|
|
20
|
+
* Zod schema for a single granular change-log row in `ChangeTrackingV2`.
|
|
21
|
+
* `appliedAt` is assigned locally at insert time; incoming values are replaced.
|
|
22
|
+
*/
|
|
17
23
|
exports.changeRecordSchema = zod_1.z.object({
|
|
18
24
|
changeId: zod_types_1.zodPeerId,
|
|
19
25
|
transactionId: zod_1.z.string().optional(), // not used for now
|
|
@@ -25,9 +31,11 @@ exports.changeRecordSchema = zod_1.z.object({
|
|
|
25
31
|
.any()
|
|
26
32
|
.nullish()
|
|
27
33
|
.describe("JSON value - can be any type (object, string, number, boolean, array, null, or undefined)"),
|
|
28
|
-
createdAt: zod_1.z.number(),
|
|
29
|
-
appliedAt: zod_1.z
|
|
30
|
-
|
|
34
|
+
createdAt: zod_1.z.number().describe("Author write time from the hybrid logical clock (LWW key)"),
|
|
35
|
+
appliedAt: zod_1.z
|
|
36
|
+
.number()
|
|
37
|
+
.describe("Local apply / sync-visibility time; assigned at insert, incoming values replaced"),
|
|
38
|
+
supersededAt: zod_1.z.number().optional().describe("When this change was superseded; unset if active"),
|
|
31
39
|
});
|
|
32
40
|
const changeTrackingV2TableMetaData = {
|
|
33
41
|
name: "ChangeTrackingV2",
|
|
@@ -47,15 +55,103 @@ const changeTrackingV2TableMetaData = {
|
|
|
47
55
|
],
|
|
48
56
|
localOnly: true,
|
|
49
57
|
};
|
|
58
|
+
/**
|
|
59
|
+
* Local-only change log used for peer sync.
|
|
60
|
+
*
|
|
61
|
+
* Owns two {@link HybridLogicalClock} instances for this database:
|
|
62
|
+
* - {@link clock} stamps `createdAt` / `supersededAt` (cross-device LWW)
|
|
63
|
+
* - `appliedClock` stamps `appliedAt` at insert time (per-device sync watermark)
|
|
64
|
+
*/
|
|
50
65
|
class ChangeTrackingTable extends orm_1.SQLDataSource {
|
|
51
66
|
dataChangedEmitter;
|
|
52
67
|
dataChanged;
|
|
53
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Hybrid logical clock for author write times (`createdAt`, `supersededAt`).
|
|
70
|
+
* Observes accepted incoming `createdAt` values so later local writes order after them.
|
|
71
|
+
*/
|
|
72
|
+
clock;
|
|
73
|
+
appliedClock;
|
|
74
|
+
clockInit;
|
|
75
|
+
/**
|
|
76
|
+
* @param db - SQLite database that stores `ChangeTrackingV2`
|
|
77
|
+
* @param now - Optional physical-time source shared by both clocks (defaults to {@link getTimestamp})
|
|
78
|
+
*/
|
|
79
|
+
constructor({ db, now }) {
|
|
54
80
|
super(db, changeTrackingV2TableMetaData, exports.changeRecordSchema);
|
|
81
|
+
const physicalNow = now ?? utils_1.getTimestamp;
|
|
82
|
+
this.clock = new hlc_1.HybridLogicalClock({ now: physicalNow });
|
|
83
|
+
this.appliedClock = new hlc_1.HybridLogicalClock({
|
|
84
|
+
now: physicalNow,
|
|
85
|
+
maxSkewMs: Number.POSITIVE_INFINITY,
|
|
86
|
+
});
|
|
55
87
|
const tableName = changeTrackingV2TableMetaData.name;
|
|
56
88
|
this.dataChangedEmitter = new events_1.Emitter(`${tableName}_DataChanged_${(0, utils_1.newid)()}`);
|
|
57
89
|
this.dataChanged = this.dataChangedEmitter.event;
|
|
58
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Ensures the table exists, seeds both clocks from durable history, and
|
|
93
|
+
* rewrites any far-future `createdAt` rows this device still owns.
|
|
94
|
+
*
|
|
95
|
+
* @param force - Re-run table migration and clock seeding
|
|
96
|
+
*/
|
|
97
|
+
async initTable(force = false) {
|
|
98
|
+
const created = await super.initTable(force);
|
|
99
|
+
if (!this.clockInit || force) {
|
|
100
|
+
this.clockInit = this.seedClocksAndRepair();
|
|
101
|
+
}
|
|
102
|
+
await this.clockInit;
|
|
103
|
+
return created;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Drop the change-tracking table and clear memoized clock seeding so the next
|
|
107
|
+
* {@link initTable} reseeds from an empty (or newly created) table.
|
|
108
|
+
*/
|
|
109
|
+
async dropTableIfExists() {
|
|
110
|
+
await super.dropTableIfExists();
|
|
111
|
+
this.clockInit = undefined;
|
|
112
|
+
}
|
|
113
|
+
async seedClocksAndRepair() {
|
|
114
|
+
const physicalNow = this.clock.now();
|
|
115
|
+
const cutoff = physicalNow + peer_device_1.PeerDeviceConsts.CHANGE_TIMESTAMP_TOLERANCE_MS;
|
|
116
|
+
const bounds = await this.db.get(`SELECT
|
|
117
|
+
MAX(CASE WHEN createdAt <= ? THEN createdAt END) as maxCreatedAt,
|
|
118
|
+
MAX(appliedAt) as maxAppliedAt
|
|
119
|
+
FROM "${this.tableName}"`, [cutoff]);
|
|
120
|
+
if (bounds?.maxAppliedAt != null) {
|
|
121
|
+
this.appliedClock.seed(Number(bounds.maxAppliedAt));
|
|
122
|
+
}
|
|
123
|
+
if (bounds?.maxCreatedAt != null) {
|
|
124
|
+
this.clock.seed(Number(bounds.maxCreatedAt));
|
|
125
|
+
}
|
|
126
|
+
await this.repairFutureTimestamps();
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Rewrite local rows whose `createdAt` is beyond the change-timestamp bound.
|
|
130
|
+
* `createdAt` is restamped from {@link clock} (near wall time) and `appliedAt`
|
|
131
|
+
* from the unbounded apply clock so peers whose watermark already passed the
|
|
132
|
+
* old value will pull the repaired rows.
|
|
133
|
+
*
|
|
134
|
+
* @returns Number of rows rewritten
|
|
135
|
+
*/
|
|
136
|
+
async repairFutureTimestamps() {
|
|
137
|
+
await super.initTable();
|
|
138
|
+
const cutoff = this.clock.now() + peer_device_1.PeerDeviceConsts.CHANGE_TIMESTAMP_TOLERANCE_MS;
|
|
139
|
+
const inBoundMax = await this.db.get(`SELECT MAX(createdAt) as maxCreatedAt FROM "${this.tableName}" WHERE createdAt <= ?`, [cutoff]);
|
|
140
|
+
if (inBoundMax?.maxCreatedAt != null) {
|
|
141
|
+
this.clock.seed(Number(inBoundMax.maxCreatedAt));
|
|
142
|
+
}
|
|
143
|
+
const rows = await this.db.all(`SELECT changeId FROM "${this.tableName}" WHERE createdAt > ? ORDER BY createdAt ASC`, [cutoff]);
|
|
144
|
+
if (rows.length === 0) {
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
for (const row of rows) {
|
|
148
|
+
const createdAt = this.clock.tick();
|
|
149
|
+
const appliedAt = this.appliedClock.tick();
|
|
150
|
+
await this.db.exec(`UPDATE "${this.tableName}" SET createdAt = ?, appliedAt = ? WHERE changeId = ?`, [createdAt, appliedAt, row.changeId]);
|
|
151
|
+
}
|
|
152
|
+
console.log(`Repaired ${rows.length} change tracking row(s) with timestamps too far in the future`);
|
|
153
|
+
return rows.length;
|
|
154
|
+
}
|
|
59
155
|
/**
|
|
60
156
|
* Efficiently find which recordIds have been deleted.
|
|
61
157
|
*
|
|
@@ -144,6 +240,10 @@ class ChangeTrackingTable extends orm_1.SQLDataSource {
|
|
|
144
240
|
`, changeIds);
|
|
145
241
|
return missingChangeIds.map((d) => d.changeId);
|
|
146
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Mark every still-active change for `recordId` whose `createdAt` is older
|
|
245
|
+
* than `supersededAt`. Used after a root snapshot, delete, or restore.
|
|
246
|
+
*/
|
|
147
247
|
async markAllPriorChangesSuperseded(tableName, recordId, supersededAt) {
|
|
148
248
|
await this.db.exec(`
|
|
149
249
|
UPDATE "${this.tableName}"
|
|
@@ -193,9 +293,12 @@ class ChangeTrackingTable extends orm_1.SQLDataSource {
|
|
|
193
293
|
return record ? this.deserializeValue(record) : undefined;
|
|
194
294
|
}
|
|
195
295
|
/**
|
|
196
|
-
*
|
|
296
|
+
* Insert a change row, assigning `appliedAt` from the local apply clock
|
|
297
|
+
* immediately before the SQL statement so watermarks stay monotonic.
|
|
197
298
|
*/
|
|
198
299
|
async insert(record) {
|
|
300
|
+
await this.initTable();
|
|
301
|
+
record.appliedAt = this.appliedClock.tick();
|
|
199
302
|
if (record.value !== undefined) {
|
|
200
303
|
record.value = (0, serial_json_1.toJSONString)(record.value);
|
|
201
304
|
}
|
|
@@ -216,8 +319,10 @@ class ChangeTrackingTable extends orm_1.SQLDataSource {
|
|
|
216
319
|
/**
|
|
217
320
|
* Synchronous insert for use within transactions.
|
|
218
321
|
* Table must be initialized before calling this method.
|
|
322
|
+
* Assigns `appliedAt` from the local apply clock immediately before the SQL statement.
|
|
219
323
|
*/
|
|
220
324
|
insertSync(record) {
|
|
325
|
+
record.appliedAt = this.appliedClock.tick();
|
|
221
326
|
if (record.value !== undefined) {
|
|
222
327
|
record = { ...record, value: (0, serial_json_1.toJSONString)(record.value) };
|
|
223
328
|
}
|
|
@@ -4,9 +4,9 @@ exports.Connection = void 0;
|
|
|
4
4
|
exports.normalizeAddress = normalizeAddress;
|
|
5
5
|
const data_1 = require("../data");
|
|
6
6
|
const keys_1 = require("../keys");
|
|
7
|
+
const peer_device_1 = require("../types/peer-device");
|
|
7
8
|
const utils_1 = require("../utils");
|
|
8
9
|
const socket_type_1 = require("./socket.type");
|
|
9
|
-
const HANDSHAKE_TIMESTAMP_TOLERANCE_MS = 300_000; // 5 minutes
|
|
10
10
|
/** Authenticated RPC connection between two Peers devices. */
|
|
11
11
|
class Connection {
|
|
12
12
|
localDevice;
|
|
@@ -26,7 +26,7 @@ class Connection {
|
|
|
26
26
|
onHandshakeComplete;
|
|
27
27
|
timeoutMs = 60_000; // 60 seconds
|
|
28
28
|
maxChunkSize = socket_type_1.DEFAULT_MAX_CHUNK_SIZE;
|
|
29
|
-
handshakeTimestampToleranceMs =
|
|
29
|
+
handshakeTimestampToleranceMs = peer_device_1.PeerDeviceConsts.CLOCK_SKEW_TOLERANCE_MS;
|
|
30
30
|
_connectionAddress;
|
|
31
31
|
get connectionAddress() {
|
|
32
32
|
return this._connectionAddress;
|
package/dist/hlc.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for {@link HybridLogicalClock}.
|
|
3
|
+
*/
|
|
4
|
+
export interface IHybridLogicalClockOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Physical-time source in milliseconds. Defaults to {@link getTimestamp}
|
|
7
|
+
* so HLC ticks stay on the same clock as existing change-log rows and tests.
|
|
8
|
+
* (`Date.now` and `performance.timeOrigin + performance.now` drift apart
|
|
9
|
+
* in long-running processes.) Injected in tests to simulate clock skew.
|
|
10
|
+
*/
|
|
11
|
+
now?: () => number;
|
|
12
|
+
/**
|
|
13
|
+
* Incoming timestamps more than this many ms ahead of {@link HybridLogicalClock.now}
|
|
14
|
+
* are ignored by {@link HybridLogicalClock.observe}. Defaults to
|
|
15
|
+
* {@link PeerDeviceConsts.CHANGE_TIMESTAMP_TOLERANCE_MS}.
|
|
16
|
+
*/
|
|
17
|
+
maxSkewMs?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Compact hybrid logical clock stored as a single millisecond number.
|
|
21
|
+
*
|
|
22
|
+
* `tick()` is `max(now(), last + 1)` so local timestamps stay close to wall time
|
|
23
|
+
* while remaining strictly monotonic even when the system clock jumps backward.
|
|
24
|
+
* `observe(ts)` raises `last` to a received timestamp so a later local tick
|
|
25
|
+
* always orders after a change this device has already seen.
|
|
26
|
+
*/
|
|
27
|
+
export declare class HybridLogicalClock {
|
|
28
|
+
private last;
|
|
29
|
+
private readonly getNow;
|
|
30
|
+
private readonly maxSkewMs;
|
|
31
|
+
/**
|
|
32
|
+
* @param opts - Optional physical-time source and observe-skew bound
|
|
33
|
+
*/
|
|
34
|
+
constructor(opts?: IHybridLogicalClockOptions);
|
|
35
|
+
/**
|
|
36
|
+
* Current physical time from the injected `now` function. Does not advance the clock.
|
|
37
|
+
*/
|
|
38
|
+
now(): number;
|
|
39
|
+
/**
|
|
40
|
+
* Last assigned or observed value, or `0` before the first tick/seed/observe.
|
|
41
|
+
*/
|
|
42
|
+
get value(): number;
|
|
43
|
+
/**
|
|
44
|
+
* Assign the next timestamp: `last = max(now(), last + 1)`.
|
|
45
|
+
*
|
|
46
|
+
* @returns The newly assigned timestamp
|
|
47
|
+
*/
|
|
48
|
+
tick(): number;
|
|
49
|
+
/**
|
|
50
|
+
* Incorporate a timestamp received from another device.
|
|
51
|
+
* Values that are not finite, or that exceed `now() + maxSkewMs`, are ignored
|
|
52
|
+
* so one misconfigured peer cannot push this clock into the far future.
|
|
53
|
+
*
|
|
54
|
+
* @param ts - Received timestamp
|
|
55
|
+
* @returns `true` if the clock absorbed `ts`
|
|
56
|
+
*/
|
|
57
|
+
observe(ts: number): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Raise `last` to at least `ts` without consulting the skew bound.
|
|
60
|
+
* Used when seeding from durable local history on startup.
|
|
61
|
+
*
|
|
62
|
+
* @param ts - Known-good timestamp from local storage
|
|
63
|
+
*/
|
|
64
|
+
seed(ts: number): void;
|
|
65
|
+
}
|
package/dist/hlc.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HybridLogicalClock = void 0;
|
|
4
|
+
const peer_device_1 = require("./types/peer-device");
|
|
5
|
+
const utils_1 = require("./utils");
|
|
6
|
+
/**
|
|
7
|
+
* Compact hybrid logical clock stored as a single millisecond number.
|
|
8
|
+
*
|
|
9
|
+
* `tick()` is `max(now(), last + 1)` so local timestamps stay close to wall time
|
|
10
|
+
* while remaining strictly monotonic even when the system clock jumps backward.
|
|
11
|
+
* `observe(ts)` raises `last` to a received timestamp so a later local tick
|
|
12
|
+
* always orders after a change this device has already seen.
|
|
13
|
+
*/
|
|
14
|
+
class HybridLogicalClock {
|
|
15
|
+
last = 0;
|
|
16
|
+
getNow;
|
|
17
|
+
maxSkewMs;
|
|
18
|
+
/**
|
|
19
|
+
* @param opts - Optional physical-time source and observe-skew bound
|
|
20
|
+
*/
|
|
21
|
+
constructor(opts) {
|
|
22
|
+
this.getNow = opts?.now ?? utils_1.getTimestamp;
|
|
23
|
+
this.maxSkewMs = opts?.maxSkewMs ?? peer_device_1.PeerDeviceConsts.CHANGE_TIMESTAMP_TOLERANCE_MS;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Current physical time from the injected `now` function. Does not advance the clock.
|
|
27
|
+
*/
|
|
28
|
+
now() {
|
|
29
|
+
return this.getNow();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Last assigned or observed value, or `0` before the first tick/seed/observe.
|
|
33
|
+
*/
|
|
34
|
+
get value() {
|
|
35
|
+
return this.last;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Assign the next timestamp: `last = max(now(), last + 1)`.
|
|
39
|
+
*
|
|
40
|
+
* @returns The newly assigned timestamp
|
|
41
|
+
*/
|
|
42
|
+
tick() {
|
|
43
|
+
this.last = Math.max(this.getNow(), this.last + 1);
|
|
44
|
+
return this.last;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Incorporate a timestamp received from another device.
|
|
48
|
+
* Values that are not finite, or that exceed `now() + maxSkewMs`, are ignored
|
|
49
|
+
* so one misconfigured peer cannot push this clock into the far future.
|
|
50
|
+
*
|
|
51
|
+
* @param ts - Received timestamp
|
|
52
|
+
* @returns `true` if the clock absorbed `ts`
|
|
53
|
+
*/
|
|
54
|
+
observe(ts) {
|
|
55
|
+
if (!Number.isFinite(ts)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (ts > this.getNow() + this.maxSkewMs) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
this.last = Math.max(this.last, ts);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Raise `last` to at least `ts` without consulting the skew bound.
|
|
66
|
+
* Used when seeding from durable local history on startup.
|
|
67
|
+
*
|
|
68
|
+
* @param ts - Known-good timestamp from local storage
|
|
69
|
+
*/
|
|
70
|
+
seed(ts) {
|
|
71
|
+
if (!Number.isFinite(ts)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
this.last = Math.max(this.last, ts);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
exports.HybridLogicalClock = HybridLogicalClock;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/hlc.test.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const hlc_1 = require("./hlc");
|
|
4
|
+
const peer_device_1 = require("./types/peer-device");
|
|
5
|
+
describe("HybridLogicalClock", () => {
|
|
6
|
+
it("is monotonic when now goes backward", () => {
|
|
7
|
+
let t = 1000;
|
|
8
|
+
const clock = new hlc_1.HybridLogicalClock({ now: () => t });
|
|
9
|
+
const first = clock.tick();
|
|
10
|
+
t = 100;
|
|
11
|
+
const second = clock.tick();
|
|
12
|
+
expect(second).toBe(first + 1);
|
|
13
|
+
});
|
|
14
|
+
it("advances by 1 on the same millisecond", () => {
|
|
15
|
+
const clock = new hlc_1.HybridLogicalClock({ now: () => 5000 });
|
|
16
|
+
expect(clock.tick()).toBe(5000);
|
|
17
|
+
expect(clock.tick()).toBe(5001);
|
|
18
|
+
expect(clock.tick()).toBe(5002);
|
|
19
|
+
});
|
|
20
|
+
it("observe advances last so the next tick is after the observed time", () => {
|
|
21
|
+
const clock = new hlc_1.HybridLogicalClock({ now: () => 1000 });
|
|
22
|
+
expect(clock.observe(2500)).toBe(true);
|
|
23
|
+
expect(clock.tick()).toBe(2501);
|
|
24
|
+
});
|
|
25
|
+
it("observe ignores timestamps beyond the skew bound", () => {
|
|
26
|
+
const now = 10_000;
|
|
27
|
+
const clock = new hlc_1.HybridLogicalClock({ now: () => now });
|
|
28
|
+
const future = now + peer_device_1.PeerDeviceConsts.CHANGE_TIMESTAMP_TOLERANCE_MS + 1;
|
|
29
|
+
expect(clock.observe(future)).toBe(false);
|
|
30
|
+
expect(clock.tick()).toBe(now);
|
|
31
|
+
});
|
|
32
|
+
it("seed raises last without going backward", () => {
|
|
33
|
+
const clock = new hlc_1.HybridLogicalClock({ now: () => 1000 });
|
|
34
|
+
clock.seed(2000);
|
|
35
|
+
clock.seed(1500);
|
|
36
|
+
expect(clock.tick()).toBe(2001);
|
|
37
|
+
});
|
|
38
|
+
it("now peeks physical time without advancing", () => {
|
|
39
|
+
const clock = new hlc_1.HybridLogicalClock({ now: () => 42 });
|
|
40
|
+
expect(clock.now()).toBe(42);
|
|
41
|
+
expect(clock.now()).toBe(42);
|
|
42
|
+
expect(clock.value).toBe(0);
|
|
43
|
+
expect(clock.tick()).toBe(42);
|
|
44
|
+
});
|
|
45
|
+
});
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -37,6 +37,7 @@ __exportStar(require("./device/tx-encoding"), exports);
|
|
|
37
37
|
__exportStar(require("./device-pairing"), exports);
|
|
38
38
|
__exportStar(require("./events"), exports);
|
|
39
39
|
__exportStar(require("./group-invite"), exports);
|
|
40
|
+
__exportStar(require("./hlc"), exports);
|
|
40
41
|
__exportStar(require("./keys"), exports);
|
|
41
42
|
__exportStar(require("./logging"), exports);
|
|
42
43
|
__exportStar(require("./mentions"), exports);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IAlsoImplementsDeclaration, IConsumedContract, IContractDefinition, IContractObservable, IContractTool } from "../contracts/types";
|
|
1
|
+
import type { IAlsoImplementsDeclaration, IConsumedContract, IContractDefinition, IContractEvent, IContractObservable, IContractTool } from "../contracts/types";
|
|
2
2
|
import { type PersistentVarScope } from "../data/persistent-var-identity";
|
|
3
3
|
import type { IAppNav } from "../types/app-nav";
|
|
4
4
|
import { type IField } from "../types/field-type";
|
|
@@ -20,16 +20,21 @@ export interface IIsolatedContractToolRef {
|
|
|
20
20
|
/**
|
|
21
21
|
* Tool advertised on an isolated provided contract.
|
|
22
22
|
*
|
|
23
|
-
* Shape matches {@link IContractTool}. `suggestedAccessLevel` is
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* Shape matches {@link IContractTool}. `suggestedAccessLevel` is the default
|
|
24
|
+
* effective level until install-time approval exists and calls `approveTool`.
|
|
25
|
+
* Omit it to keep the fail-closed Self default.
|
|
26
26
|
*/
|
|
27
27
|
export interface IIsolatedContractTool extends Omit<IContractTool, "inputFields" | "outputFields"> {
|
|
28
28
|
/** Defaults to `[]` during validation when omitted. */
|
|
29
29
|
inputFields?: IField[];
|
|
30
30
|
/** Defaults to `[]` during validation when omitted. */
|
|
31
31
|
outputFields?: IField[];
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Default effective access level for this tool.
|
|
34
|
+
*
|
|
35
|
+
* Used until an administrator explicitly approves the tool at install time.
|
|
36
|
+
* Omit to keep the fail-closed Self default.
|
|
37
|
+
*/
|
|
33
38
|
suggestedAccessLevel?: number;
|
|
34
39
|
}
|
|
35
40
|
/**
|
|
@@ -60,6 +65,18 @@ export interface IIsolatedContractPersistentVarBinding {
|
|
|
60
65
|
export interface IIsolatedContractObservable extends IContractObservable {
|
|
61
66
|
persistentVar: IIsolatedContractPersistentVarBinding;
|
|
62
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Event an isolated provider may emit from an active tool invocation.
|
|
70
|
+
*
|
|
71
|
+
* Shape matches {@link IContractEvent}. `payloadFields` defaults to `[]`
|
|
72
|
+
* during validation when omitted. Events are host-owned: the worker emits via
|
|
73
|
+
* `emitContractEvent`, and local consumers subscribe through the contract
|
|
74
|
+
* proxy. Events are fire-and-forget (no replay) and local-only in this slice.
|
|
75
|
+
*/
|
|
76
|
+
export interface IIsolatedContractEvent extends Omit<IContractEvent, "payloadFields"> {
|
|
77
|
+
/** Defaults to `[]` during validation when omitted. */
|
|
78
|
+
payloadFields?: IField[];
|
|
79
|
+
}
|
|
63
80
|
/** Contract this package implements and serves from its isolated worker. */
|
|
64
81
|
export interface IIsolatedProvidedContract {
|
|
65
82
|
contractId: string;
|
|
@@ -71,6 +88,8 @@ export interface IIsolatedProvidedContract {
|
|
|
71
88
|
tables?: IIsolatedContractTableRef[];
|
|
72
89
|
/** Pvar-backed observable values exposed to consumers. */
|
|
73
90
|
observables?: IIsolatedContractObservable[];
|
|
91
|
+
/** Events the worker may emit during an active invocation. */
|
|
92
|
+
events?: IIsolatedContractEvent[];
|
|
74
93
|
/** Older contract versions this provider shape also satisfies. */
|
|
75
94
|
alsoImplements?: IAlsoImplementsDeclaration[];
|
|
76
95
|
}
|
|
@@ -132,6 +151,8 @@ export declare const ISOLATED_CONSUME_HOST_MEMBER = "contract.consume";
|
|
|
132
151
|
export declare const ISOLATED_TABLE_HOST_MEMBER = "package.table";
|
|
133
152
|
/** Host-contract member guests use to read or write package-prefixed pvars. */
|
|
134
153
|
export declare const ISOLATED_PERSISTENT_VAR_HOST_MEMBER = "package.pvar";
|
|
154
|
+
/** Host-contract member guests use to emit a provided-contract event. */
|
|
155
|
+
export declare const ISOLATED_EVENT_HOST_MEMBER = "contract.emit";
|
|
135
156
|
/**
|
|
136
157
|
* True when `bundle` starts with {@link ISOLATED_PACKAGE_MAGIC}.
|
|
137
158
|
*
|
|
@@ -210,6 +231,24 @@ export declare function findProvidedContractTable(provides: IIsolatedProvidedCon
|
|
|
210
231
|
* @param observableName Public observable name.
|
|
211
232
|
*/
|
|
212
233
|
export declare function findProvidedContractObservable(provides: IIsolatedProvidedContract[], contractId: string, version: number, observableName: string): IIsolatedContractObservable | undefined;
|
|
234
|
+
/**
|
|
235
|
+
* Return a declared event from a provided contract.
|
|
236
|
+
*
|
|
237
|
+
* @param provides Provided contracts from the manifest.
|
|
238
|
+
* @param contractId Target contract ID.
|
|
239
|
+
* @param version Target contract version.
|
|
240
|
+
* @param eventName Event name on that contract.
|
|
241
|
+
*/
|
|
242
|
+
export declare function findProvidedContractEvent(provides: IIsolatedProvidedContract[], contractId: string, version: number, eventName: string): IIsolatedContractEvent | undefined;
|
|
243
|
+
/**
|
|
244
|
+
* True when the package advertises this event on a provided contract.
|
|
245
|
+
*
|
|
246
|
+
* @param provides - Provided contracts from the manifest.
|
|
247
|
+
* @param contractId - Target contract id.
|
|
248
|
+
* @param version - Target contract version.
|
|
249
|
+
* @param eventName - Event name on that contract.
|
|
250
|
+
*/
|
|
251
|
+
export declare function packageProvidesEvent(provides: IIsolatedProvidedContract[], contractId: string, version: number, eventName: string): boolean;
|
|
213
252
|
/**
|
|
214
253
|
* True when the package declares a private table.
|
|
215
254
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ISOLATED_PERSISTENT_VAR_HOST_MEMBER = exports.ISOLATED_TABLE_HOST_MEMBER = exports.ISOLATED_CONSUME_HOST_MEMBER = exports.ISOLATED_PACKAGE_MAGIC = void 0;
|
|
3
|
+
exports.ISOLATED_EVENT_HOST_MEMBER = exports.ISOLATED_PERSISTENT_VAR_HOST_MEMBER = exports.ISOLATED_TABLE_HOST_MEMBER = exports.ISOLATED_CONSUME_HOST_MEMBER = exports.ISOLATED_PACKAGE_MAGIC = void 0;
|
|
4
4
|
exports.looksLikeIsolatedPackageArtifact = looksLikeIsolatedPackageArtifact;
|
|
5
5
|
exports.validateIsolatedPackageArtifact = validateIsolatedPackageArtifact;
|
|
6
6
|
exports.parseIsolatedPackageArtifact = parseIsolatedPackageArtifact;
|
|
@@ -10,6 +10,8 @@ exports.packageDeclaresConsume = packageDeclaresConsume;
|
|
|
10
10
|
exports.packageProvidesTool = packageProvidesTool;
|
|
11
11
|
exports.findProvidedContractTable = findProvidedContractTable;
|
|
12
12
|
exports.findProvidedContractObservable = findProvidedContractObservable;
|
|
13
|
+
exports.findProvidedContractEvent = findProvidedContractEvent;
|
|
14
|
+
exports.packageProvidesEvent = packageProvidesEvent;
|
|
13
15
|
exports.packageProvidesTable = packageProvidesTable;
|
|
14
16
|
exports.findPackageTable = findPackageTable;
|
|
15
17
|
exports.toConsumedContracts = toConsumedContracts;
|
|
@@ -29,6 +31,8 @@ exports.ISOLATED_CONSUME_HOST_MEMBER = "contract.consume";
|
|
|
29
31
|
exports.ISOLATED_TABLE_HOST_MEMBER = "package.table";
|
|
30
32
|
/** Host-contract member guests use to read or write package-prefixed pvars. */
|
|
31
33
|
exports.ISOLATED_PERSISTENT_VAR_HOST_MEMBER = "package.pvar";
|
|
34
|
+
/** Host-contract member guests use to emit a provided-contract event. */
|
|
35
|
+
exports.ISOLATED_EVENT_HOST_MEMBER = "contract.emit";
|
|
32
36
|
const FIELD_TYPES = new Set(Object.values(field_type_1.FieldType));
|
|
33
37
|
const OBSERVABLE_FIELD_TYPES = new Set([
|
|
34
38
|
field_type_1.FieldType.id,
|
|
@@ -209,6 +213,7 @@ function validateProvidedContract(contract, index, packageTables) {
|
|
|
209
213
|
"tools",
|
|
210
214
|
"tables",
|
|
211
215
|
"observables",
|
|
216
|
+
"events",
|
|
212
217
|
"alsoImplements",
|
|
213
218
|
], label);
|
|
214
219
|
assertPeerId(contract.contractId, `${label}.contractId`);
|
|
@@ -339,7 +344,36 @@ function validateProvidedContract(contract, index, packageTables) {
|
|
|
339
344
|
pvarBindings.add(bindingKey);
|
|
340
345
|
validateObservableDefault(binding.defaultValue, observable.valueType, `${observableLabel}.persistentVar.defaultValue`);
|
|
341
346
|
}
|
|
342
|
-
if (contract.
|
|
347
|
+
if (contract.events === undefined) {
|
|
348
|
+
contract.events = [];
|
|
349
|
+
}
|
|
350
|
+
if (!Array.isArray(contract.events)) {
|
|
351
|
+
throw new Error(`${label}.events must be an array`);
|
|
352
|
+
}
|
|
353
|
+
const eventNames = new Set();
|
|
354
|
+
for (const [eventIndex, event] of contract.events.entries()) {
|
|
355
|
+
const eventLabel = `${label}.events[${eventIndex}]`;
|
|
356
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
|
357
|
+
throw new Error(`${eventLabel} must be an object`);
|
|
358
|
+
}
|
|
359
|
+
assertOnlyKeys(event, ["name", "description", "payloadFields"], eventLabel);
|
|
360
|
+
if (typeof event.name !== "string" || event.name.trim() === "") {
|
|
361
|
+
throw new Error(`${eventLabel}.name is required`);
|
|
362
|
+
}
|
|
363
|
+
if (eventNames.has(event.name)) {
|
|
364
|
+
throw new Error(`${label} declares duplicate event "${event.name}"`);
|
|
365
|
+
}
|
|
366
|
+
eventNames.add(event.name);
|
|
367
|
+
if (typeof event.description !== "string") {
|
|
368
|
+
throw new Error(`${eventLabel}.description is required`);
|
|
369
|
+
}
|
|
370
|
+
event.payloadFields = validateFields(event.payloadFields, `${eventLabel}.payloadFields`);
|
|
371
|
+
}
|
|
372
|
+
if (contract.tools.length +
|
|
373
|
+
contract.tables.length +
|
|
374
|
+
contract.observables.length +
|
|
375
|
+
contract.events.length ===
|
|
376
|
+
0) {
|
|
343
377
|
throw new Error(`${label} must declare at least one contract member`);
|
|
344
378
|
}
|
|
345
379
|
if (contract.alsoImplements === undefined) {
|
|
@@ -564,6 +598,11 @@ function isolatedContractToDefinition(contract, packageTables) {
|
|
|
564
598
|
valueType: observable.valueType,
|
|
565
599
|
writable: observable.writable,
|
|
566
600
|
}));
|
|
601
|
+
const events = (contract.events ?? []).map((event) => ({
|
|
602
|
+
name: event.name,
|
|
603
|
+
description: event.description,
|
|
604
|
+
payloadFields: event.payloadFields ?? [],
|
|
605
|
+
}));
|
|
567
606
|
return {
|
|
568
607
|
contractId: contract.contractId,
|
|
569
608
|
version: contract.version,
|
|
@@ -572,7 +611,7 @@ function isolatedContractToDefinition(contract, packageTables) {
|
|
|
572
611
|
tables,
|
|
573
612
|
tools,
|
|
574
613
|
observables,
|
|
575
|
-
events
|
|
614
|
+
events,
|
|
576
615
|
};
|
|
577
616
|
}
|
|
578
617
|
/**
|
|
@@ -623,6 +662,30 @@ function findProvidedContractObservable(provides, contractId, version, observabl
|
|
|
623
662
|
.find((contract) => contract.contractId === contractId && contract.version === version)
|
|
624
663
|
?.observables?.find((observable) => observable.name === observableName);
|
|
625
664
|
}
|
|
665
|
+
/**
|
|
666
|
+
* Return a declared event from a provided contract.
|
|
667
|
+
*
|
|
668
|
+
* @param provides Provided contracts from the manifest.
|
|
669
|
+
* @param contractId Target contract ID.
|
|
670
|
+
* @param version Target contract version.
|
|
671
|
+
* @param eventName Event name on that contract.
|
|
672
|
+
*/
|
|
673
|
+
function findProvidedContractEvent(provides, contractId, version, eventName) {
|
|
674
|
+
return provides
|
|
675
|
+
.find((contract) => contract.contractId === contractId && contract.version === version)
|
|
676
|
+
?.events?.find((event) => event.name === eventName);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* True when the package advertises this event on a provided contract.
|
|
680
|
+
*
|
|
681
|
+
* @param provides - Provided contracts from the manifest.
|
|
682
|
+
* @param contractId - Target contract id.
|
|
683
|
+
* @param version - Target contract version.
|
|
684
|
+
* @param eventName - Event name on that contract.
|
|
685
|
+
*/
|
|
686
|
+
function packageProvidesEvent(provides, contractId, version, eventName) {
|
|
687
|
+
return findProvidedContractEvent(provides, contractId, version, eventName) !== undefined;
|
|
688
|
+
}
|
|
626
689
|
/**
|
|
627
690
|
* True when the package declares a private table.
|
|
628
691
|
*
|
|
@@ -235,12 +235,60 @@ describe("isolated package artifact", () => {
|
|
|
235
235
|
{ contractId: targetContractId, version: { from: 2, to: 4 } },
|
|
236
236
|
]);
|
|
237
237
|
});
|
|
238
|
-
test("
|
|
238
|
+
test("maps declared events to the public contract definition", () => {
|
|
239
239
|
const { artifact } = makeArtifact();
|
|
240
240
|
artifact.manifest.provides[0].events = [
|
|
241
|
-
{
|
|
241
|
+
{
|
|
242
|
+
name: "changed",
|
|
243
|
+
description: "State changed",
|
|
244
|
+
payloadFields: [{ name: "count", type: field_type_1.FieldType.number }],
|
|
245
|
+
},
|
|
246
|
+
{ name: "pinged", description: "No payload" },
|
|
242
247
|
];
|
|
243
|
-
|
|
248
|
+
const manifest = (0, isolated_artifact_1.validateIsolatedPackageArtifact)(artifact);
|
|
249
|
+
expect(manifest.provides[0].events?.[1].payloadFields).toEqual([]);
|
|
250
|
+
const definition = (0, isolated_artifact_1.isolatedContractToDefinition)(manifest.provides[0], manifest.tables ?? []);
|
|
251
|
+
expect(definition.events).toEqual([
|
|
252
|
+
{
|
|
253
|
+
name: "changed",
|
|
254
|
+
description: "State changed",
|
|
255
|
+
payloadFields: [{ name: "count", type: field_type_1.FieldType.number }],
|
|
256
|
+
},
|
|
257
|
+
{ name: "pinged", description: "No payload", payloadFields: [] },
|
|
258
|
+
]);
|
|
259
|
+
expect((0, isolated_artifact_1.findProvidedContractEvent)(manifest.provides, definition.contractId, 1, "changed")).toBe(manifest.provides[0].events?.[0]);
|
|
260
|
+
expect((0, isolated_artifact_1.packageProvidesEvent)(manifest.provides, definition.contractId, 1, "missing")).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
test("allows an events-only contract", () => {
|
|
263
|
+
const { artifact } = makeArtifact();
|
|
264
|
+
artifact.manifest.provides[0].tools = [];
|
|
265
|
+
artifact.manifest.provides[0].events = [{ name: "changed", description: "State changed" }];
|
|
266
|
+
expect(() => (0, isolated_artifact_1.validateIsolatedPackageArtifact)(artifact)).not.toThrow();
|
|
267
|
+
artifact.manifest.provides[0].events = [];
|
|
268
|
+
expect(() => (0, isolated_artifact_1.validateIsolatedPackageArtifact)(artifact)).toThrow(/at least one contract member/);
|
|
269
|
+
});
|
|
270
|
+
test("rejects malformed or duplicate events", () => {
|
|
271
|
+
const { artifact } = makeArtifact();
|
|
272
|
+
artifact.manifest.provides[0].events = [
|
|
273
|
+
{ name: "changed", description: "a" },
|
|
274
|
+
{ name: "changed", description: "b" },
|
|
275
|
+
];
|
|
276
|
+
expect(() => (0, isolated_artifact_1.validateIsolatedPackageArtifact)(artifact)).toThrow(/duplicate event "changed"/);
|
|
277
|
+
artifact.manifest.provides[0].events = [{ name: "changed" }];
|
|
278
|
+
expect(() => (0, isolated_artifact_1.validateIsolatedPackageArtifact)(artifact)).toThrow(/description is required/);
|
|
279
|
+
artifact.manifest.provides[0].events = [
|
|
280
|
+
{ name: "changed", description: "a", accessLevel: 50 },
|
|
281
|
+
];
|
|
282
|
+
expect(() => (0, isolated_artifact_1.validateIsolatedPackageArtifact)(artifact)).toThrow(/accessLevel is not supported/);
|
|
283
|
+
artifact.manifest.provides[0].events = [
|
|
284
|
+
{ name: "changed", description: "a", payloadFields: [{ name: "x", type: "nope" }] },
|
|
285
|
+
];
|
|
286
|
+
expect(() => (0, isolated_artifact_1.validateIsolatedPackageArtifact)(artifact)).toThrow(/type must be a FieldType/);
|
|
287
|
+
});
|
|
288
|
+
test("rejects unsupported contract features instead of dropping them", () => {
|
|
289
|
+
const { artifact } = makeArtifact();
|
|
290
|
+
artifact.manifest.provides[0].streams = [];
|
|
291
|
+
expect(() => (0, isolated_artifact_1.validateIsolatedPackageArtifact)(artifact)).toThrow(/streams is not supported/);
|
|
244
292
|
});
|
|
245
293
|
test("rejects missing public table references and malformed observable bindings", () => {
|
|
246
294
|
const { artifact } = makeArtifact();
|
|
@@ -126,7 +126,19 @@ export interface IIsolatedPackageTableGateway {
|
|
|
126
126
|
* The host validates declaration and provider identity before calling the gateway.
|
|
127
127
|
*/
|
|
128
128
|
call(ref: IIsolatedPackageTableRef, operation: IsolatedContractTableOperation, args: unknown[]): Promise<unknown>;
|
|
129
|
+
/**
|
|
130
|
+
* Subscribe to a host-owned table event. Only `dataChanged` is supported;
|
|
131
|
+
* payloads are copied before delivery. The host validates declaration and
|
|
132
|
+
* contract exposure before calling the gateway.
|
|
133
|
+
*
|
|
134
|
+
* @returns A callback that detaches the host-side subscription.
|
|
135
|
+
*/
|
|
136
|
+
subscribe(ref: IIsolatedPackageTableRef, eventName: IsolatedContractTableEvent, handler: (event: unknown) => void): Promise<() => void>;
|
|
129
137
|
}
|
|
138
|
+
/** Table events consumers may subscribe to on an isolated contract table. */
|
|
139
|
+
export declare const ISOLATED_CONTRACT_TABLE_EVENTS: readonly ["dataChanged"];
|
|
140
|
+
/** Consumer-subscribable event on an isolated contract table. */
|
|
141
|
+
export type IsolatedContractTableEvent = (typeof ISOLATED_CONTRACT_TABLE_EVENTS)[number];
|
|
130
142
|
/**
|
|
131
143
|
* Trusted identity of one package-owned pvar.
|
|
132
144
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ISOLATED_CONTRACT_TABLE_OPERATIONS = void 0;
|
|
3
|
+
exports.ISOLATED_CONTRACT_TABLE_EVENTS = exports.ISOLATED_CONTRACT_TABLE_OPERATIONS = void 0;
|
|
4
4
|
exports.setIsolatedPackageRuntimeFactory = setIsolatedPackageRuntimeFactory;
|
|
5
5
|
exports.getIsolatedPackageRuntimeFactory = getIsolatedPackageRuntimeFactory;
|
|
6
6
|
/** Standard table methods exposed through isolated contract-table references. */
|
|
@@ -14,6 +14,8 @@ exports.ISOLATED_CONTRACT_TABLE_OPERATIONS = [
|
|
|
14
14
|
"update",
|
|
15
15
|
"delete",
|
|
16
16
|
];
|
|
17
|
+
/** Table events consumers may subscribe to on an isolated contract table. */
|
|
18
|
+
exports.ISOLATED_CONTRACT_TABLE_EVENTS = ["dataChanged"];
|
|
17
19
|
let isolatedRuntimeFactory;
|
|
18
20
|
/**
|
|
19
21
|
* Register the process-wide isolated package runtime factory.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DataContext } from "../context/data-context";
|
|
2
|
-
import type
|
|
2
|
+
import { type IIsolatedPackageTableGateway } from "./isolated-runtime";
|
|
3
3
|
/**
|
|
4
4
|
* In-memory isolated table gateway for tests. Bindings and rows survive
|
|
5
5
|
* `register` of the same owner so unload/reinstall can resume persisted data.
|
|
@@ -6,7 +6,9 @@ const table_container_1 = require("../data/orm/table-container");
|
|
|
6
6
|
const types_1 = require("../data/orm/types");
|
|
7
7
|
const package_table_identity_1 = require("../data/package-table-identity");
|
|
8
8
|
const table_definitions_table_1 = require("../data/table-definitions-table");
|
|
9
|
+
const events_1 = require("../events");
|
|
9
10
|
const utils_1 = require("../utils");
|
|
11
|
+
const isolated_runtime_1 = require("./isolated-runtime");
|
|
10
12
|
const isolated_value_1 = require("./isolated-value");
|
|
11
13
|
function tableKey(ref) {
|
|
12
14
|
return `${ref.packageId}:${ref.tableName}`;
|
|
@@ -159,6 +161,12 @@ function copyTableResult(value) {
|
|
|
159
161
|
function matchesFilter(row, filter = {}) {
|
|
160
162
|
return Object.entries(filter).every(([name, value]) => row[name] === value);
|
|
161
163
|
}
|
|
164
|
+
function assertTableEvent(eventName) {
|
|
165
|
+
if (!isolated_runtime_1.ISOLATED_CONTRACT_TABLE_EVENTS.includes(eventName)) {
|
|
166
|
+
throw new Error(`Unsupported isolated table event '${eventName}'`);
|
|
167
|
+
}
|
|
168
|
+
return eventName;
|
|
169
|
+
}
|
|
162
170
|
/**
|
|
163
171
|
* In-memory isolated table gateway for tests. Bindings and rows survive
|
|
164
172
|
* `register` of the same owner so unload/reinstall can resume persisted data.
|
|
@@ -169,6 +177,19 @@ function createMemoryIsolatedPackageTableGateway() {
|
|
|
169
177
|
const declarations = new Map();
|
|
170
178
|
const byTableId = new Map();
|
|
171
179
|
const rows = new Map();
|
|
180
|
+
const emitters = new Map();
|
|
181
|
+
const emitterFor = (key) => {
|
|
182
|
+
let emitter = emitters.get(key);
|
|
183
|
+
if (!emitter) {
|
|
184
|
+
// Unique per gateway instance so parallel tests never share a global event name.
|
|
185
|
+
emitter = new events_1.Emitter(`isolated-memory-table:${(0, utils_1.newid)()}:${key}`);
|
|
186
|
+
emitters.set(key, emitter);
|
|
187
|
+
}
|
|
188
|
+
return emitter;
|
|
189
|
+
};
|
|
190
|
+
const emitChange = (key, op, dataObject) => {
|
|
191
|
+
void emitterFor(key).emit({ dataObject, op, source: "local" });
|
|
192
|
+
};
|
|
172
193
|
const registerBatch = async (batch) => {
|
|
173
194
|
const candidateDeclarations = new Map(declarations);
|
|
174
195
|
const candidateByTableId = new Map(byTableId);
|
|
@@ -213,6 +234,7 @@ function createMemoryIsolatedPackageTableGateway() {
|
|
|
213
234
|
const tableRows = rows.get(key) ?? new Map();
|
|
214
235
|
tableRows.set(recordId, stored);
|
|
215
236
|
rows.set(key, tableRows);
|
|
237
|
+
emitChange(key, "save", stored);
|
|
216
238
|
return (0, isolated_value_1.copyIsolatedValue)(stored);
|
|
217
239
|
},
|
|
218
240
|
async call(ref, operation, args) {
|
|
@@ -259,6 +281,7 @@ function createMemoryIsolatedPackageTableGateway() {
|
|
|
259
281
|
throw new Error(`Record ${id} does not exist`);
|
|
260
282
|
}
|
|
261
283
|
tableRows.set(id, stored);
|
|
284
|
+
emitChange(key, operation, stored);
|
|
262
285
|
return (0, isolated_value_1.copyIsolatedValue)(stored);
|
|
263
286
|
}
|
|
264
287
|
case "delete": {
|
|
@@ -268,11 +291,28 @@ function createMemoryIsolatedPackageTableGateway() {
|
|
|
268
291
|
if (typeof id !== "string") {
|
|
269
292
|
throw new Error(`Isolated table delete requires "${declaration.primaryKeyName}"`);
|
|
270
293
|
}
|
|
294
|
+
const existing = tableRows.get(id);
|
|
271
295
|
tableRows.delete(id);
|
|
296
|
+
if (existing) {
|
|
297
|
+
emitChange(key, "delete", existing);
|
|
298
|
+
}
|
|
272
299
|
return undefined;
|
|
273
300
|
}
|
|
274
301
|
}
|
|
275
302
|
},
|
|
303
|
+
async subscribe(ref, eventName, handler) {
|
|
304
|
+
assertTableEvent(eventName);
|
|
305
|
+
const key = tableKey(ref);
|
|
306
|
+
if (!declarations.has(key)) {
|
|
307
|
+
throw new Error(`Undeclared isolated table ${ref.tableName}`);
|
|
308
|
+
}
|
|
309
|
+
const subscription = emitterFor(key).event.subscribe((event) => {
|
|
310
|
+
handler((0, isolated_value_1.copyIsolatedValue)(event));
|
|
311
|
+
});
|
|
312
|
+
return () => {
|
|
313
|
+
subscription.unsubscribe();
|
|
314
|
+
};
|
|
315
|
+
},
|
|
276
316
|
};
|
|
277
317
|
}
|
|
278
318
|
/**
|
|
@@ -371,5 +411,19 @@ function createIsolatedPackageTableGateway(dataContext) {
|
|
|
371
411
|
const result = await method.apply(table, (0, isolated_value_1.copyIsolatedValue)(args));
|
|
372
412
|
return copyTableResult(result);
|
|
373
413
|
},
|
|
414
|
+
async subscribe(ref, eventName, handler) {
|
|
415
|
+
assertTableEvent(eventName);
|
|
416
|
+
await registrationQueue;
|
|
417
|
+
const table = await ensureTable(resolve(ref));
|
|
418
|
+
const subscription = table.dataChanged.subscribe((event) => {
|
|
419
|
+
handler({
|
|
420
|
+
...event,
|
|
421
|
+
dataObject: copyTableResult(event.dataObject),
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
return () => {
|
|
425
|
+
subscription.unsubscribe();
|
|
426
|
+
};
|
|
427
|
+
},
|
|
374
428
|
};
|
|
375
429
|
}
|
|
@@ -203,4 +203,65 @@ describe("isolated package table gateway", () => {
|
|
|
203
203
|
count: 4,
|
|
204
204
|
});
|
|
205
205
|
});
|
|
206
|
+
test("memory gateway delivers copied dataChanged events for save/call and detaches", async () => {
|
|
207
|
+
const gateway = (0, isolated_table_gateway_1.createMemoryIsolatedPackageTableGateway)();
|
|
208
|
+
const declaration = makeDeclaration();
|
|
209
|
+
const other = makeDeclaration();
|
|
210
|
+
const recordId = (0, utils_1.newid)();
|
|
211
|
+
await gateway.registerBatch([declaration, other]);
|
|
212
|
+
const received = [];
|
|
213
|
+
const unsubscribe = await gateway.subscribe(declaration, "dataChanged", (event) => {
|
|
214
|
+
received.push(event);
|
|
215
|
+
});
|
|
216
|
+
await gateway.save(declaration, { stateId: recordId, count: 1 });
|
|
217
|
+
await gateway.call(declaration, "update", [{ stateId: recordId, count: 2 }]);
|
|
218
|
+
await gateway.save(other, { stateId: (0, utils_1.newid)(), count: 9 });
|
|
219
|
+
await gateway.call(declaration, "delete", [recordId]);
|
|
220
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
221
|
+
expect(received).toEqual([
|
|
222
|
+
{ dataObject: { stateId: recordId, count: 1 }, op: "save", source: "local" },
|
|
223
|
+
{ dataObject: { stateId: recordId, count: 2 }, op: "update", source: "local" },
|
|
224
|
+
{ dataObject: { stateId: recordId, count: 2 }, op: "delete", source: "local" },
|
|
225
|
+
]);
|
|
226
|
+
const first = received[0];
|
|
227
|
+
first.dataObject.count = 99;
|
|
228
|
+
await expect(gateway.get(declaration, recordId)).resolves.toBeUndefined();
|
|
229
|
+
unsubscribe();
|
|
230
|
+
await gateway.save(declaration, { stateId: recordId, count: 3 });
|
|
231
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
232
|
+
expect(received).toHaveLength(3);
|
|
233
|
+
await expect(gateway.subscribe(declaration, "somethingElse", () => undefined)).rejects.toThrow(/Unsupported isolated table event/);
|
|
234
|
+
await expect(gateway.subscribe(makeDeclaration(), "dataChanged", () => undefined)).rejects.toThrow(/Undeclared isolated table/);
|
|
235
|
+
});
|
|
236
|
+
test("data-context gateway subscribes to the host table dataChanged event", async () => {
|
|
237
|
+
const declaration = makeDeclaration();
|
|
238
|
+
const handlers = new Set();
|
|
239
|
+
const table = {
|
|
240
|
+
get: jest.fn(),
|
|
241
|
+
save: jest.fn(),
|
|
242
|
+
dataChanged: {
|
|
243
|
+
subscribe: jest.fn((handler) => {
|
|
244
|
+
handlers.add(handler);
|
|
245
|
+
return { unsubscribe: () => handlers.delete(handler) };
|
|
246
|
+
}),
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
const gateway = (0, isolated_table_gateway_1.createIsolatedPackageTableGateway)({
|
|
250
|
+
tableContainer: { registerTableDefinition: jest.fn(), getTable: jest.fn(() => table) },
|
|
251
|
+
});
|
|
252
|
+
await gateway.register(declaration);
|
|
253
|
+
const received = [];
|
|
254
|
+
const unsubscribe = await gateway.subscribe(declaration, "dataChanged", (event) => {
|
|
255
|
+
received.push(event);
|
|
256
|
+
});
|
|
257
|
+
expect(table.dataChanged.subscribe).toHaveBeenCalledTimes(1);
|
|
258
|
+
const row = { stateId: (0, utils_1.newid)(), count: 1 };
|
|
259
|
+
for (const handler of handlers) {
|
|
260
|
+
handler({ dataObject: row, op: "save", source: "local" });
|
|
261
|
+
}
|
|
262
|
+
expect(received).toEqual([{ dataObject: row, op: "save", source: "local" }]);
|
|
263
|
+
expect(received[0].dataObject).not.toBe(row);
|
|
264
|
+
unsubscribe();
|
|
265
|
+
expect(handlers.size).toBe(0);
|
|
266
|
+
});
|
|
206
267
|
});
|
|
@@ -81,6 +81,14 @@ export declare const PeerDeviceConsts: Readonly<{
|
|
|
81
81
|
readonly TIMEOUT_MIN: 3000;
|
|
82
82
|
readonly TIMEOUT_MAX: 30000;
|
|
83
83
|
readonly NETWORK_INFO_CACHE_TIME: 1000;
|
|
84
|
+
/** Handshake rejects peers whose wall clock differs by more than this. */
|
|
85
|
+
readonly CLOCK_SKEW_TOLERANCE_MS: 300000;
|
|
86
|
+
/**
|
|
87
|
+
* Incoming change `createdAt` values more than this far ahead of local now
|
|
88
|
+
* are skipped. 2× the handshake bound so multi-hop HLC lead plus allowed
|
|
89
|
+
* skew does not reject legitimate changes.
|
|
90
|
+
*/
|
|
91
|
+
readonly CHANGE_TIMESTAMP_TOLERANCE_MS: 600000;
|
|
84
92
|
}>;
|
|
85
93
|
/** Availability and queue information for a requested file chunk. */
|
|
86
94
|
export type IFileChunkInfo = {
|
|
@@ -13,4 +13,12 @@ exports.PeerDeviceConsts = Object.freeze({
|
|
|
13
13
|
TIMEOUT_MIN: 3_000,
|
|
14
14
|
TIMEOUT_MAX: 30_000,
|
|
15
15
|
NETWORK_INFO_CACHE_TIME: 1000,
|
|
16
|
+
/** Handshake rejects peers whose wall clock differs by more than this. */
|
|
17
|
+
CLOCK_SKEW_TOLERANCE_MS: 300_000,
|
|
18
|
+
/**
|
|
19
|
+
* Incoming change `createdAt` values more than this far ahead of local now
|
|
20
|
+
* are skipped. 2× the handshake bound so multi-hop HLC lead plus allowed
|
|
21
|
+
* skew does not reject legitimate changes.
|
|
22
|
+
*/
|
|
23
|
+
CHANGE_TIMESTAMP_TOLERANCE_MS: 600_000,
|
|
16
24
|
});
|