@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.
@@ -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
- constructor({ db }: {
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
- * Override to serialize value field to JSON string
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.number(),
30
- supersededAt: zod_1.z.number().optional(),
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
- constructor({ db }) {
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
- * Override to serialize value field to JSON string
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 = HANDSHAKE_TIMESTAMP_TOLERANCE_MS;
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 {};
@@ -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
@@ -16,6 +16,7 @@ export * from "./device/tx-encoding";
16
16
  export * from "./device-pairing";
17
17
  export * from "./events";
18
18
  export * from "./group-invite";
19
+ export * from "./hlc";
19
20
  export * from "./keys";
20
21
  export * from "./logging";
21
22
  export * from "./mentions";
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 a package
24
- * hint only — the host keeps the effective level at Self until an
25
- * administrator approves it.
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
- /** Package suggestion only. Effective level stays Self until approved. */
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.tools.length + contract.tables.length + contract.observables.length === 0) {
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("rejects unsupported contract features instead of dropping them", () => {
238
+ test("maps declared events to the public contract definition", () => {
239
239
  const { artifact } = makeArtifact();
240
240
  artifact.manifest.provides[0].events = [
241
- { name: "changed", payloadFields: [] },
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
- expect(() => (0, isolated_artifact_1.validateIsolatedPackageArtifact)(artifact)).toThrow(/events is not supported/);
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 { IIsolatedPackageTableGateway } from "./isolated-runtime";
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-sdk",
3
- "version": "0.23.11",
3
+ "version": "0.23.13",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-sdk.git"