@teincfood/core 0.1.6 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/kv.js +37 -2
- package/dist/db/migrations.js +10 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/reference/capabilities.d.ts +3 -0
- package/dist/reference/capabilities.js +36 -0
- package/dist/reference/lww.d.ts +14 -0
- package/dist/reference/lww.js +45 -0
- package/dist/reference/lww.test.d.ts +1 -0
- package/dist/reference/lww.test.js +23 -0
- package/dist/reference/operations.d.ts +1 -0
- package/dist/reference/operations.js +36 -4
- package/dist/reference/operations.test.d.ts +1 -0
- package/dist/reference/operations.test.js +66 -0
- package/dist/reference/service.js +2 -3
- package/dist/reference/types.d.ts +4 -0
- package/dist/standalone.integration.test.d.ts +1 -0
- package/dist/standalone.integration.test.js +235 -0
- package/dist/sync/command-queue.service.js +29 -5
- package/dist/sync/device-lease.service.d.ts +19 -0
- package/dist/sync/device-lease.service.js +90 -0
- package/dist/sync/device-lease.test.d.ts +1 -0
- package/dist/sync/device-lease.test.js +38 -0
- package/dist/sync/engine.js +21 -0
- package/dist/sync/offline-capable.js +19 -0
- package/dist/sync/offline-integration.test.d.ts +1 -0
- package/dist/sync/offline-integration.test.js +52 -0
- package/dist/sync/order-number.d.ts +21 -17
- package/dist/sync/order-number.js +69 -43
- package/dist/sync/order-number.test.js +44 -23
- package/dist/sync/perms.test.d.ts +1 -0
- package/dist/sync/perms.test.js +15 -0
- package/dist/sync/use-device-lease.d.ts +11 -0
- package/dist/sync/use-device-lease.js +40 -0
- package/dist/sync/use-sync-reconciliation.js +1 -1
- package/dist/test-helpers/memory-driver.d.ts +8 -0
- package/dist/test-helpers/memory-driver.js +242 -0
- package/dist/utils/constants.d.ts +3 -3
- package/dist/utils/constants.js +1 -1
- package/dist/utils/hlc.d.ts +18 -0
- package/dist/utils/hlc.js +60 -0
- package/dist/utils/hlc.test.d.ts +1 -0
- package/dist/utils/hlc.test.js +29 -0
- package/package.json +1 -1
|
@@ -22,7 +22,7 @@ function isLocalOrderEvent(event) {
|
|
|
22
22
|
if (!payload)
|
|
23
23
|
return false;
|
|
24
24
|
const localOrderNumber = payload.local_order_number;
|
|
25
|
-
return typeof localOrderNumber === "string" && localOrderNumber
|
|
25
|
+
return typeof localOrderNumber === "string" && /^[0-9]{2}-[0-9]{3}-[0-9]{3}$/.test(localOrderNumber);
|
|
26
26
|
}
|
|
27
27
|
export function useSyncReconciliation() {
|
|
28
28
|
const queryClient = useQueryClient();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory SQLite driver for standalone integration tests (0.7.0).
|
|
3
|
+
* Covers migrations, reference_data, entity_snapshots, commands, events.
|
|
4
|
+
* No SQL parsing — string-contains dispatch, JSON payloads.
|
|
5
|
+
*/
|
|
6
|
+
import type { SqliteDriver } from "../adapters/sqlite";
|
|
7
|
+
export declare function createMemoryDriver(): SqliteDriver;
|
|
8
|
+
export declare function resetMemoryDriver(): void;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
export function createMemoryDriver() {
|
|
2
|
+
const tables = {
|
|
3
|
+
migrations: [],
|
|
4
|
+
reference_data: [],
|
|
5
|
+
entity_snapshots: [],
|
|
6
|
+
commands: [],
|
|
7
|
+
events: [],
|
|
8
|
+
};
|
|
9
|
+
let autoId = { commands: 1, events: 1, migrations: 1 };
|
|
10
|
+
const driver = {
|
|
11
|
+
async execAsync(_sql) {
|
|
12
|
+
// No-op: CREATE TABLE / INDEX / ALTER all just ensure table array exists.
|
|
13
|
+
// For ALTER ADD COLUMN we don't need to do anything — rows are schemaless.
|
|
14
|
+
return;
|
|
15
|
+
},
|
|
16
|
+
async runAsync(sql, ...args) {
|
|
17
|
+
const s = sql.replace(/\s+/g, " ").trim();
|
|
18
|
+
// migrations insert
|
|
19
|
+
if (s.includes("INSERT INTO migrations")) {
|
|
20
|
+
const version = args[0];
|
|
21
|
+
tables.migrations.push({ version, applied_at: args[1] });
|
|
22
|
+
return { lastInsertRowId: autoId.migrations++, changes: 1 };
|
|
23
|
+
}
|
|
24
|
+
// reference_data insert
|
|
25
|
+
if (s.includes("INSERT INTO reference_data")) {
|
|
26
|
+
const [business_id, key, version, payload, downloaded_at, last_event_id, last_hlc] = args;
|
|
27
|
+
const idx = tables.reference_data.findIndex((r) => r.business_id === business_id && r.key === key);
|
|
28
|
+
const row = { business_id, key, version, payload, downloaded_at, last_event_id, last_hlc };
|
|
29
|
+
if (idx >= 0)
|
|
30
|
+
tables.reference_data[idx] = row;
|
|
31
|
+
else
|
|
32
|
+
tables.reference_data.push(row);
|
|
33
|
+
return { lastInsertRowId: 1, changes: 1 };
|
|
34
|
+
}
|
|
35
|
+
// reference_data delete
|
|
36
|
+
if (s.includes("DELETE FROM reference_data WHERE business_id = ? AND key =")) {
|
|
37
|
+
const [business_id, key] = args;
|
|
38
|
+
tables.reference_data = tables.reference_data.filter((r) => !(r.business_id === business_id && r.key === key));
|
|
39
|
+
return { lastInsertRowId: 0, changes: 1 };
|
|
40
|
+
}
|
|
41
|
+
if (s.includes("DELETE FROM reference_data WHERE business_id = ?")) {
|
|
42
|
+
const [business_id] = args;
|
|
43
|
+
tables.reference_data = tables.reference_data.filter((r) => r.business_id !== business_id);
|
|
44
|
+
return { lastInsertRowId: 0, changes: 1 };
|
|
45
|
+
}
|
|
46
|
+
// entity_snapshots insert
|
|
47
|
+
if (s.includes("INSERT INTO entity_snapshots")) {
|
|
48
|
+
const [entity_type, entity_id, business_id, last_event_id, last_event_timestamp, payload] = args;
|
|
49
|
+
const idx = tables.entity_snapshots.findIndex((r) => r.entity_type === entity_type && r.entity_id === entity_id);
|
|
50
|
+
const row = { entity_type, entity_id, business_id, last_event_id, last_event_timestamp, payload };
|
|
51
|
+
if (idx >= 0)
|
|
52
|
+
tables.entity_snapshots[idx] = row;
|
|
53
|
+
else
|
|
54
|
+
tables.entity_snapshots.push(row);
|
|
55
|
+
return { lastInsertRowId: 1, changes: 1 };
|
|
56
|
+
}
|
|
57
|
+
// entity_snapshots delete
|
|
58
|
+
if (s.includes("DELETE FROM entity_snapshots WHERE entity_type = ? AND entity_id =")) {
|
|
59
|
+
const [entity_type, entity_id] = args;
|
|
60
|
+
tables.entity_snapshots = tables.entity_snapshots.filter((r) => !(r.entity_type === entity_type && r.entity_id === entity_id));
|
|
61
|
+
return { lastInsertRowId: 0, changes: 1 };
|
|
62
|
+
}
|
|
63
|
+
if (s.includes("DELETE FROM entity_snapshots WHERE entity_type = ? AND last_event_timestamp <")) {
|
|
64
|
+
const [entity_type, cutoff] = args;
|
|
65
|
+
tables.entity_snapshots = tables.entity_snapshots.filter((r) => !(r.entity_type === entity_type && r.last_event_timestamp < cutoff));
|
|
66
|
+
return { lastInsertRowId: 0, changes: 1 };
|
|
67
|
+
}
|
|
68
|
+
// commands insert
|
|
69
|
+
if (s.includes("INSERT INTO commands")) {
|
|
70
|
+
const [command_id, command_type, command_version, business_id, entity_id, user_id, device_id, payload, status] = args;
|
|
71
|
+
const existing = tables.commands.find((r) => r.command_id === command_id);
|
|
72
|
+
const row = {
|
|
73
|
+
id: existing ? existing.id : autoId.commands++,
|
|
74
|
+
command_id,
|
|
75
|
+
command_type,
|
|
76
|
+
command_version,
|
|
77
|
+
business_id,
|
|
78
|
+
entity_id,
|
|
79
|
+
user_id,
|
|
80
|
+
device_id,
|
|
81
|
+
payload,
|
|
82
|
+
status,
|
|
83
|
+
error: null,
|
|
84
|
+
retry_count: 0,
|
|
85
|
+
last_attempted_at: null,
|
|
86
|
+
created_at: args[11],
|
|
87
|
+
updated_at: args[12],
|
|
88
|
+
timestamp: new Date().toISOString(),
|
|
89
|
+
};
|
|
90
|
+
if (existing)
|
|
91
|
+
Object.assign(existing, row);
|
|
92
|
+
else
|
|
93
|
+
tables.commands.push(row);
|
|
94
|
+
return { lastInsertRowId: row.id, changes: 1 };
|
|
95
|
+
}
|
|
96
|
+
// commands update status
|
|
97
|
+
if (s.includes("UPDATE commands SET status = ?")) {
|
|
98
|
+
const [status, error, _now, _now2, command_id] = args;
|
|
99
|
+
// also handles variants with retry_count increment
|
|
100
|
+
const row = tables.commands.find((r) => r.command_id === command_id);
|
|
101
|
+
if (row) {
|
|
102
|
+
row.status = status;
|
|
103
|
+
row.error = error;
|
|
104
|
+
if (s.includes("retry_count = retry_count + 1"))
|
|
105
|
+
row.retry_count += 1;
|
|
106
|
+
}
|
|
107
|
+
return { lastInsertRowId: 0, changes: 1 };
|
|
108
|
+
}
|
|
109
|
+
if (s.includes("UPDATE commands SET status = 'in_flight'")) {
|
|
110
|
+
const [now, _now2, command_id] = args;
|
|
111
|
+
const row = tables.commands.find((r) => r.command_id === command_id);
|
|
112
|
+
if (row) {
|
|
113
|
+
row.status = "in_flight";
|
|
114
|
+
row.last_attempted_at = now;
|
|
115
|
+
}
|
|
116
|
+
return { lastInsertRowId: 0, changes: 1 };
|
|
117
|
+
}
|
|
118
|
+
if (s.includes("DELETE FROM commands WHERE command_id")) {
|
|
119
|
+
const [command_id] = args;
|
|
120
|
+
tables.commands = tables.commands.filter((r) => r.command_id !== command_id);
|
|
121
|
+
return { lastInsertRowId: 0, changes: 1 };
|
|
122
|
+
}
|
|
123
|
+
if (s.includes("DELETE FROM commands WHERE status = 'completed'")) {
|
|
124
|
+
const [cutoff] = args;
|
|
125
|
+
tables.commands = tables.commands.filter((r) => !(r.status === "completed" && r.updated_at < cutoff));
|
|
126
|
+
return { lastInsertRowId: 0, changes: 1 };
|
|
127
|
+
}
|
|
128
|
+
if (s.includes("UPDATE commands SET payload")) {
|
|
129
|
+
const [payload, command_id] = args;
|
|
130
|
+
const row = tables.commands.find((r) => r.command_id === command_id);
|
|
131
|
+
if (row)
|
|
132
|
+
row.payload = payload;
|
|
133
|
+
return { lastInsertRowId: 0, changes: 1 };
|
|
134
|
+
}
|
|
135
|
+
// events insert
|
|
136
|
+
if (s.includes("INSERT INTO events")) {
|
|
137
|
+
const [event_id, event_type, event_version, entity_id, user_id, device_id, payload, origin, synced_to_cloud, timestamp, persisted_at] = args;
|
|
138
|
+
if (tables.events.find((r) => r.event_id === event_id))
|
|
139
|
+
return { lastInsertRowId: 0, changes: 0 };
|
|
140
|
+
const row = {
|
|
141
|
+
id: autoId.events++,
|
|
142
|
+
event_id,
|
|
143
|
+
event_type,
|
|
144
|
+
event_version,
|
|
145
|
+
entity_id,
|
|
146
|
+
user_id,
|
|
147
|
+
device_id,
|
|
148
|
+
payload,
|
|
149
|
+
origin,
|
|
150
|
+
synced_to_cloud,
|
|
151
|
+
timestamp,
|
|
152
|
+
persisted_at,
|
|
153
|
+
};
|
|
154
|
+
tables.events.push(row);
|
|
155
|
+
return { lastInsertRowId: row.id, changes: 1 };
|
|
156
|
+
}
|
|
157
|
+
// events update synced
|
|
158
|
+
if (s.includes("UPDATE events SET synced_to_cloud = 1 WHERE event_id IN")) {
|
|
159
|
+
const ids = args;
|
|
160
|
+
for (const row of tables.events)
|
|
161
|
+
if (ids.includes(row.event_id))
|
|
162
|
+
row.synced_to_cloud = 1;
|
|
163
|
+
return { lastInsertRowId: 0, changes: ids.length };
|
|
164
|
+
}
|
|
165
|
+
return { lastInsertRowId: 0, changes: 0 };
|
|
166
|
+
},
|
|
167
|
+
async getFirstAsync(sql, ...args) {
|
|
168
|
+
const s = sql.replace(/\s+/g, " ").trim();
|
|
169
|
+
if (s.includes("SELECT version FROM migrations WHERE version =")) {
|
|
170
|
+
const version = args[0];
|
|
171
|
+
const row = tables.migrations.find((r) => r.version === version);
|
|
172
|
+
return (row ? { version: row.version } : null);
|
|
173
|
+
}
|
|
174
|
+
if (s.includes("SELECT * FROM reference_data WHERE business_id = ? AND key =")) {
|
|
175
|
+
const [business_id, key] = args;
|
|
176
|
+
const row = tables.reference_data.find((r) => r.business_id === business_id && r.key === key);
|
|
177
|
+
return row ?? null;
|
|
178
|
+
}
|
|
179
|
+
if (s.includes("SELECT version FROM reference_data WHERE business_id = ? AND key =")) {
|
|
180
|
+
const [business_id, key] = args;
|
|
181
|
+
const row = tables.reference_data.find((r) => r.business_id === business_id && r.key === key);
|
|
182
|
+
return (row ? { version: row.version } : null);
|
|
183
|
+
}
|
|
184
|
+
if (s.includes("SELECT last_event_timestamp FROM entity_snapshots WHERE entity_type = ? AND entity_id =")) {
|
|
185
|
+
const [entity_type, entity_id] = args;
|
|
186
|
+
const row = tables.entity_snapshots.find((r) => r.entity_type === entity_type && r.entity_id === entity_id);
|
|
187
|
+
return (row ? { last_event_timestamp: row.last_event_timestamp } : null);
|
|
188
|
+
}
|
|
189
|
+
if (s.includes("SELECT * FROM entity_snapshots WHERE entity_type = ? AND entity_id =")) {
|
|
190
|
+
const [entity_type, entity_id] = args;
|
|
191
|
+
const row = tables.entity_snapshots.find((r) => r.entity_type === entity_type && r.entity_id === entity_id);
|
|
192
|
+
return row ?? null;
|
|
193
|
+
}
|
|
194
|
+
if (s.includes("SELECT * FROM commands WHERE id =")) {
|
|
195
|
+
const id = args[0];
|
|
196
|
+
const row = tables.commands.find((r) => r.id === id);
|
|
197
|
+
return row ?? null;
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
},
|
|
201
|
+
async getAllAsync(sql, ...args) {
|
|
202
|
+
const s = sql.replace(/\s+/g, " ").trim();
|
|
203
|
+
if (s.includes("SELECT key FROM reference_data WHERE business_id = ? ORDER BY key")) {
|
|
204
|
+
const [business_id] = args;
|
|
205
|
+
return tables.reference_data.filter((r) => r.business_id === business_id).map((r) => ({ key: r.key }));
|
|
206
|
+
}
|
|
207
|
+
if (s.includes("SELECT * FROM entity_snapshots WHERE entity_type = ? ORDER BY last_event_timestamp DESC")) {
|
|
208
|
+
const [entity_type] = args;
|
|
209
|
+
const rows = tables.entity_snapshots.filter((r) => r.entity_type === entity_type).sort((a, b) => (a.last_event_timestamp < b.last_event_timestamp ? 1 : -1));
|
|
210
|
+
return rows;
|
|
211
|
+
}
|
|
212
|
+
if (s.includes("SELECT * FROM commands") && s.includes("WHERE status IN")) {
|
|
213
|
+
const limit = args[args.length - 1];
|
|
214
|
+
let rows = tables.commands.filter((r) => r.status === "pending" || r.status === "failed").sort((a, b) => (a.created_at < b.created_at ? -1 : 1));
|
|
215
|
+
if (limit)
|
|
216
|
+
rows = rows.slice(0, limit);
|
|
217
|
+
return rows;
|
|
218
|
+
}
|
|
219
|
+
if (s.includes("SELECT * FROM events") && s.includes("WHERE entity_id = ?")) {
|
|
220
|
+
const [entity_id, limit] = args;
|
|
221
|
+
let rows = tables.events.filter((r) => r.entity_id === entity_id).sort((a, b) => (a.timestamp < b.timestamp ? -1 : 1));
|
|
222
|
+
if (limit)
|
|
223
|
+
rows = rows.slice(0, limit);
|
|
224
|
+
return rows;
|
|
225
|
+
}
|
|
226
|
+
if (s.includes("SELECT * FROM events") && s.includes("WHERE origin = ? AND synced_to_cloud = 0")) {
|
|
227
|
+
const [origin, limit] = args;
|
|
228
|
+
let rows = tables.events.filter((r) => r.origin === origin && r.synced_to_cloud === 0).sort((a, b) => (a.timestamp < b.timestamp ? -1 : 1));
|
|
229
|
+
if (limit && limit !== Number.MAX_SAFE_INTEGER)
|
|
230
|
+
rows = rows.slice(0, limit);
|
|
231
|
+
return rows;
|
|
232
|
+
}
|
|
233
|
+
return [];
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
// expose tables for inspection in tests
|
|
237
|
+
driver._tables = tables;
|
|
238
|
+
return driver;
|
|
239
|
+
}
|
|
240
|
+
export function resetMemoryDriver() {
|
|
241
|
+
// helper for isolation — callers create new driver per test
|
|
242
|
+
}
|
|
@@ -10,7 +10,7 @@ export declare function setApiBaseUrl(url: string | undefined): void;
|
|
|
10
10
|
export declare function setWsBaseUrl(url: string | undefined): void;
|
|
11
11
|
/** App metadata */
|
|
12
12
|
export declare const APP_NAME = "Teinc Food Business";
|
|
13
|
-
export declare const APP_VERSION = "
|
|
13
|
+
export declare const APP_VERSION = "0.7.1";
|
|
14
14
|
/** Async storage keys */
|
|
15
15
|
export declare const STORAGE_KEYS: {
|
|
16
16
|
readonly AUTH_TOKEN: "auth_token";
|
|
@@ -49,13 +49,13 @@ export declare const DEFAULT_PAGE_SIZE = 20;
|
|
|
49
49
|
* Keeps the terminal responsive (fast) even when the internet is slow.
|
|
50
50
|
*/
|
|
51
51
|
export declare const CLOUD_COMMAND_TIMEOUT_MS = 2500;
|
|
52
|
-
export declare const appVersion = "
|
|
52
|
+
export declare const appVersion = "0.7.1";
|
|
53
53
|
/** Default config bundle for createTeincCore overrides */
|
|
54
54
|
export declare const DEFAULTS: {
|
|
55
55
|
readonly API_BASE_URL: string;
|
|
56
56
|
readonly WS_BASE_URL: string;
|
|
57
57
|
readonly APP_NAME: "Teinc Food Business";
|
|
58
|
-
readonly APP_VERSION: "
|
|
58
|
+
readonly APP_VERSION: "0.7.1";
|
|
59
59
|
readonly STORAGE_KEYS: {
|
|
60
60
|
readonly AUTH_TOKEN: "auth_token";
|
|
61
61
|
readonly REFRESH_TOKEN: "refresh_token";
|
package/dist/utils/constants.js
CHANGED
|
@@ -38,7 +38,7 @@ export function setWsBaseUrl(url) {
|
|
|
38
38
|
}
|
|
39
39
|
/** App metadata */
|
|
40
40
|
export const APP_NAME = "Teinc Food Business";
|
|
41
|
-
export const APP_VERSION = "
|
|
41
|
+
export const APP_VERSION = "0.7.1";
|
|
42
42
|
/** Async storage keys */
|
|
43
43
|
export const STORAGE_KEYS = {
|
|
44
44
|
AUTH_TOKEN: "auth_token",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hybrid Logical Clock (HLC) for offline-first causal ordering.
|
|
3
|
+
*
|
|
4
|
+
* Encodes as "wallMs-counter-deviceId" (e.g. "1714560000000-2-01").
|
|
5
|
+
* Ordering: wallMs → counter → deviceId lexical.
|
|
6
|
+
* Guarantees monotonic per-device even if wall clock skews.
|
|
7
|
+
*/
|
|
8
|
+
export interface HLC {
|
|
9
|
+
wallMs: number;
|
|
10
|
+
counter: number;
|
|
11
|
+
deviceId: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function hlcToString(h: HLC): string;
|
|
14
|
+
export declare function hlcFromString(s: string): HLC | null;
|
|
15
|
+
export declare function compareHLC(a: HLC, b: HLC): number;
|
|
16
|
+
export declare function compareHLCString(a: string, b: string): number;
|
|
17
|
+
export declare function tickHLC(wallMs: number, last: HLC | null, deviceId: string): HLC;
|
|
18
|
+
export declare function maxHLC(a: HLC | null, b: HLC | null): HLC | null;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hybrid Logical Clock (HLC) for offline-first causal ordering.
|
|
3
|
+
*
|
|
4
|
+
* Encodes as "wallMs-counter-deviceId" (e.g. "1714560000000-2-01").
|
|
5
|
+
* Ordering: wallMs → counter → deviceId lexical.
|
|
6
|
+
* Guarantees monotonic per-device even if wall clock skews.
|
|
7
|
+
*/
|
|
8
|
+
export function hlcToString(h) {
|
|
9
|
+
return `${h.wallMs}-${String(h.counter).padStart(6, "0")}-${h.deviceId}`;
|
|
10
|
+
}
|
|
11
|
+
export function hlcFromString(s) {
|
|
12
|
+
const parts = s.split("-");
|
|
13
|
+
if (parts.length < 3)
|
|
14
|
+
return null;
|
|
15
|
+
const wallMs = Number(parts[0]);
|
|
16
|
+
const counter = Number(parts[1]);
|
|
17
|
+
const deviceId = parts.slice(2).join("-");
|
|
18
|
+
if (!Number.isFinite(wallMs) || !Number.isFinite(counter) || !deviceId)
|
|
19
|
+
return null;
|
|
20
|
+
return { wallMs, counter, deviceId };
|
|
21
|
+
}
|
|
22
|
+
export function compareHLC(a, b) {
|
|
23
|
+
if (a.wallMs !== b.wallMs)
|
|
24
|
+
return a.wallMs - b.wallMs;
|
|
25
|
+
if (a.counter !== b.counter)
|
|
26
|
+
return a.counter - b.counter;
|
|
27
|
+
if (a.deviceId < b.deviceId)
|
|
28
|
+
return -1;
|
|
29
|
+
if (a.deviceId > b.deviceId)
|
|
30
|
+
return 1;
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
export function compareHLCString(a, b) {
|
|
34
|
+
const ha = hlcFromString(a);
|
|
35
|
+
const hb = hlcFromString(b);
|
|
36
|
+
if (!ha && !hb)
|
|
37
|
+
return a.localeCompare(b);
|
|
38
|
+
if (!ha)
|
|
39
|
+
return -1;
|
|
40
|
+
if (!hb)
|
|
41
|
+
return 1;
|
|
42
|
+
return compareHLC(ha, hb);
|
|
43
|
+
}
|
|
44
|
+
export function tickHLC(wallMs, last, deviceId) {
|
|
45
|
+
if (!last)
|
|
46
|
+
return { wallMs, counter: 0, deviceId };
|
|
47
|
+
if (wallMs > last.wallMs)
|
|
48
|
+
return { wallMs, counter: 0, deviceId };
|
|
49
|
+
if (wallMs === last.wallMs)
|
|
50
|
+
return { wallMs, counter: last.counter + 1, deviceId };
|
|
51
|
+
// wallMs < last.wallMs (clock skew) — bump counter
|
|
52
|
+
return { wallMs: last.wallMs, counter: last.counter + 1, deviceId };
|
|
53
|
+
}
|
|
54
|
+
export function maxHLC(a, b) {
|
|
55
|
+
if (!a)
|
|
56
|
+
return b;
|
|
57
|
+
if (!b)
|
|
58
|
+
return a;
|
|
59
|
+
return compareHLC(a, b) >= 0 ? a : b;
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { tickHLC, compareHLCString, hlcToString, hlcFromString } from "./hlc";
|
|
3
|
+
describe("HLC", () => {
|
|
4
|
+
it("monotonic wall", () => {
|
|
5
|
+
const a = tickHLC(1000, null, "01");
|
|
6
|
+
const b = tickHLC(1001, a, "01");
|
|
7
|
+
expect(compareHLCString(hlcToString(b), hlcToString(a))).toBeGreaterThan(0);
|
|
8
|
+
});
|
|
9
|
+
it("counter on same wall", () => {
|
|
10
|
+
const a = tickHLC(1000, null, "01");
|
|
11
|
+
const b = tickHLC(1000, a, "01");
|
|
12
|
+
expect(b.counter).toBe(1);
|
|
13
|
+
});
|
|
14
|
+
it("skew bump", () => {
|
|
15
|
+
const a = tickHLC(1000, null, "01");
|
|
16
|
+
const b = tickHLC(999, a, "02");
|
|
17
|
+
expect(b.wallMs).toBe(1000);
|
|
18
|
+
expect(b.counter).toBe(1);
|
|
19
|
+
});
|
|
20
|
+
it("deviceId tie-break", () => {
|
|
21
|
+
const a = { wallMs: 1000, counter: 0, deviceId: "01" };
|
|
22
|
+
const b = { wallMs: 1000, counter: 0, deviceId: "02" };
|
|
23
|
+
expect(compareHLCString(hlcToString(a), hlcToString(b))).toBeLessThan(0);
|
|
24
|
+
});
|
|
25
|
+
it("roundtrip", () => {
|
|
26
|
+
const h = { wallMs: 1714560000000, counter: 2, deviceId: "10" };
|
|
27
|
+
expect(hlcFromString(hlcToString(h))).toEqual(h);
|
|
28
|
+
});
|
|
29
|
+
});
|
package/package.json
CHANGED