reflectdb 0.1.3 → 0.2.0

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.
@@ -0,0 +1,760 @@
1
+ type OpType = "insert" | "update" | "delete";
2
+ type OpStatus = "pending" | "synced" | "rejected";
3
+ /**
4
+ * Server-side conflict policy for concurrent writes to the same row.
5
+ *
6
+ * Scope note for `"merge"`: the server resolves per column using the **client
7
+ * op HLCs** recorded in reflectdb's mirror, so two clients editing different
8
+ * fields both land. The per-column clocks the client sees on a broadcast are a
9
+ * different domain — a diff-driven broadcast can't attribute a column to the
10
+ * op that produced it, so every column changed in one broadcast carries that
11
+ * broadcast's HLC. Client-side merge therefore orders *broadcasts* against each
12
+ * other and against local optimistic state; it does not reconstruct per-column
13
+ * causality between clients. Column-level convergence is a server guarantee.
14
+ */
15
+ type ConflictPolicy = "lww" | "merge" | "server" | CustomConflictPolicy;
16
+ interface CustomConflictPolicy {
17
+ policy: "custom";
18
+ resolve: ConflictResolver;
19
+ }
20
+ interface ConflictResolver {
21
+ (incoming: {
22
+ op: OpType;
23
+ payload: Record<string, unknown> | null;
24
+ hlc: string;
25
+ }, existing: {
26
+ row: Record<string, unknown>;
27
+ colClocks: Record<string, string>;
28
+ }, meta: {
29
+ table: string;
30
+ rowId: string;
31
+ userId: string;
32
+ }): {
33
+ row: Record<string, unknown>;
34
+ };
35
+ }
36
+ type ErrorReason = "outside_shape" | "readonly_query" | "rate_limited" | "buffer_full" | "server_conflict" | "custom_conflict" | "merge_stale" | "clock_drift" | "replay" | "batch_too_large" | "schema_outdated" | "auth_revoked" | "compacted" | "mutation_rejected" | "server_error" | "unknown_query";
37
+ interface HelloMessage {
38
+ type: "hello";
39
+ /** Highest protocol version the client prefers. Retained for back-compat. */
40
+ protocolVersion: number;
41
+ /** All versions the client can speak. Omit for legacy single-version clients. */
42
+ supportedVersions?: readonly number[];
43
+ clientId: string;
44
+ token: string;
45
+ }
46
+ interface BootstrapMessage {
47
+ type: "bootstrap";
48
+ syncParams?: Record<string, Record<string, unknown>>;
49
+ }
50
+ interface ResumeMessage {
51
+ type: "resume";
52
+ since: string;
53
+ }
54
+ interface OpsMessage {
55
+ type: "ops";
56
+ ops: ClientOp[];
57
+ token: string;
58
+ }
59
+ interface ClientOp {
60
+ id: string;
61
+ table: string;
62
+ op: OpType;
63
+ rowId: string;
64
+ payload: Record<string, unknown> | null;
65
+ hlc: string;
66
+ batchId?: string;
67
+ batchSize?: number;
68
+ batchSeq?: number;
69
+ }
70
+ interface SyncDeclareMessage {
71
+ type: "sync_declare";
72
+ table: string;
73
+ params?: Record<string, unknown>;
74
+ window?: number;
75
+ }
76
+ interface LoadMoreMessage {
77
+ type: "load_more";
78
+ table: string;
79
+ count: number;
80
+ }
81
+ interface UnsyncMessage {
82
+ type: "unsync";
83
+ table: string;
84
+ }
85
+ interface AuthMessage {
86
+ type: "auth";
87
+ token: string;
88
+ }
89
+ interface EphemeralMessage {
90
+ type: "ephemeral";
91
+ key: string;
92
+ userId: string;
93
+ data: Record<string, unknown>;
94
+ ttlMs?: number;
95
+ }
96
+ interface HelloAckMessage {
97
+ type: "hello_ack";
98
+ protocolVersion: number;
99
+ serverId: string;
100
+ }
101
+ interface HelloRejectMessage {
102
+ type: "hello_reject";
103
+ reason: string;
104
+ supported: number[];
105
+ }
106
+ interface SnapshotMessage {
107
+ type: "snapshot";
108
+ table: string;
109
+ rows: Record<string, unknown>[];
110
+ colClocks: Record<string, Record<string, string>>;
111
+ append?: boolean;
112
+ totalCount?: number;
113
+ /**
114
+ * Primary key column name for this table. Self-contained per message so the
115
+ * client doesn't depend on `bootstrap_complete.tableMeta` arriving first.
116
+ * When omitted (older servers), the client falls back to tableMeta, then "id".
117
+ */
118
+ pk?: string;
119
+ }
120
+ interface BootstrapCompleteMessage {
121
+ type: "bootstrap_complete";
122
+ serverHlc: string;
123
+ tableMeta: Record<string, TableMeta>;
124
+ }
125
+ interface TableMeta {
126
+ serverSet: string[];
127
+ readonly: string[];
128
+ broadcast: "consistent" | "eager" | "eager-durable";
129
+ /** Primary key column name. Defaults to "id" when omitted (older servers). */
130
+ pk?: string;
131
+ }
132
+ interface DeltaMessage {
133
+ type: "delta";
134
+ table: string;
135
+ op: OpType;
136
+ rowId: string;
137
+ payload: Record<string, unknown> | null;
138
+ hlc: string;
139
+ colClocks?: Record<string, string>;
140
+ }
141
+ interface AckMessage {
142
+ type: "ack";
143
+ opIds: string[];
144
+ }
145
+ interface RejectMessage {
146
+ type: "reject";
147
+ opId?: string;
148
+ batchId?: string;
149
+ reason: ErrorReason;
150
+ serverRow?: Record<string, unknown>;
151
+ }
152
+ interface ResumeCompleteMessage {
153
+ type: "resume_complete";
154
+ serverHlc: string;
155
+ }
156
+ interface ResumeRejectedMessage {
157
+ type: "resume_rejected";
158
+ reason: string;
159
+ serverHlc: string;
160
+ }
161
+ interface ShapeChangedMessage {
162
+ type: "shape_changed";
163
+ table: string;
164
+ reason: "auth_changed" | "params_changed" | "server_policy";
165
+ }
166
+ interface DisconnectMessage {
167
+ type: "disconnect";
168
+ reason: string;
169
+ }
170
+ interface ReauthMessage {
171
+ type: "reauth";
172
+ }
173
+ interface CountChangedMessage {
174
+ type: "count_changed";
175
+ table: string;
176
+ totalCount: number;
177
+ }
178
+ interface EphemeralEvent {
179
+ type: "ephemeral";
180
+ key: string;
181
+ clientId: string;
182
+ userId: string;
183
+ data: Record<string, unknown>;
184
+ ttlMs?: number;
185
+ }
186
+ type ClientMessage = HelloMessage | BootstrapMessage | ResumeMessage | OpsMessage | SyncDeclareMessage | LoadMoreMessage | UnsyncMessage | AuthMessage | EphemeralMessage;
187
+ type ServerMessage = HelloAckMessage | HelloRejectMessage | SnapshotMessage | BootstrapCompleteMessage | DeltaMessage | AckMessage | RejectMessage | ResumeCompleteMessage | ResumeRejectedMessage | ShapeChangedMessage | DisconnectMessage | ReauthMessage | EphemeralEvent | CountChangedMessage;
188
+ interface ClientTransport {
189
+ send(message: ClientMessage): Promise<void>;
190
+ subscribe(handler: (message: ServerMessage) => void): void;
191
+ close(): Promise<void>;
192
+ }
193
+ interface ClientStorageAdapter {
194
+ getRows(table: string): Promise<LocalRow[]>;
195
+ getAllRows(): Promise<LocalRow[]>;
196
+ putRow(table: string, rowId: string, row: LocalRow): Promise<void>;
197
+ deleteRow(table: string, rowId: string): Promise<void>;
198
+ clearTable(table: string): Promise<void>;
199
+ getPendingOps(): Promise<PendingOp[]>;
200
+ putPendingOps(ops: PendingOp[]): Promise<void>;
201
+ appendPendingOps(ops: PendingOp[]): Promise<void>;
202
+ updatePendingOps(ops: PendingOp[]): Promise<void>;
203
+ removePendingOps(opIds: string[]): Promise<void>;
204
+ clearPendingOps(): Promise<void>;
205
+ getMeta(key: string): Promise<string | null>;
206
+ setMeta(key: string, value: string): Promise<void>;
207
+ deleteMeta(key: string): Promise<void>;
208
+ clear(): Promise<void>;
209
+ }
210
+ interface PendingOp {
211
+ op: ClientOp;
212
+ status: OpStatus;
213
+ rejectedReason: ErrorReason | null;
214
+ createdAt: number;
215
+ /**
216
+ * Row state captured just before the optimistic apply. Restored on reject
217
+ * so a rejected optimistic insert collapses back to no-row (instead of
218
+ * leaving a tombstone), and a rejected update reverts to the merged row
219
+ * the client had before touching it.
220
+ */
221
+ preState?: {
222
+ data: Record<string, unknown> | null;
223
+ colClocks: Record<string, string>;
224
+ serverHlc: string | null;
225
+ } | null;
226
+ }
227
+ interface LocalRow {
228
+ table: string;
229
+ rowId: string;
230
+ data: Record<string, unknown> | null;
231
+ colClocks: Record<string, string>;
232
+ serverHlc: string | null;
233
+ }
234
+ declare class ClientStore {
235
+ private pendingOps;
236
+ private rows;
237
+ private tableMeta;
238
+ private storage;
239
+ private pendingWrites;
240
+ private writeChain;
241
+ constructor(storage?: ClientStorageAdapter);
242
+ /**
243
+ * Load durable state into memory.
244
+ *
245
+ * @param options.tables — hydrate only these tables. Rows only ever reach
246
+ * local storage through a subscription, so restoring just the tables the
247
+ * client is actually subscribed to reads no less than it needs. Omit to
248
+ * load every row of every table the client has ever synced.
249
+ */
250
+ hydrate(options?: {
251
+ tables?: string[];
252
+ }): Promise<void>;
253
+ flush(): Promise<void>;
254
+ private enqueue;
255
+ addPendingOp(op: ClientOp): void;
256
+ getPendingOps(): PendingOp[];
257
+ markSynced(opIds: string[]): void;
258
+ markRejected(opId: string, reason: ErrorReason): void;
259
+ rejectBatch(batchId: string, reason: ErrorReason): void;
260
+ private autoTrimRejected;
261
+ clearRejected(): void;
262
+ setRow(table: string, rowId: string, data: Record<string, unknown> | null, colClocks: Record<string, string>, serverHlc: string | null): void;
263
+ getRow(table: string, rowId: string): LocalRow | undefined;
264
+ /** Internal: includes tombstones for HLC comparison in applyDelta. */
265
+ private getRowEntry;
266
+ getRows(table: string, includeDeleted?: boolean): LocalRow[];
267
+ clearTable(table: string): void;
268
+ applySnapshot(table: string, rows: Record<string, unknown>[], colClocks: Record<string, Record<string, string>>, append?: boolean, pk?: string): void;
269
+ applyDelta(table: string, op: string, rowId: string, payload: Record<string, unknown> | null, hlc: string, colClocks?: Record<string, string>): void;
270
+ /**
271
+ * Guarantee a materialized row carries its own primary key.
272
+ *
273
+ * A delta's payload is not always a whole row: eager broadcasts forward the
274
+ * writer's payload verbatim, and the typed API omits the pk from what a
275
+ * client may write — so an eagerly-broadcast insert describes a row with no
276
+ * id in it. Rows are addressed by `rowId` everywhere in the protocol, so the
277
+ * key is never in doubt; only the materialized object was missing it, and
278
+ * `rows.map(r => r.id)` came back undefined.
279
+ */
280
+ private withPk;
281
+ revertOp(opId: string, serverRow: Record<string, unknown> | null | undefined): void;
282
+ setTableMeta(meta: Record<string, {
283
+ serverSet: string[];
284
+ readonly: string[];
285
+ broadcast?: "consistent" | "eager" | "eager-durable";
286
+ pk?: string;
287
+ }>): void;
288
+ getTableMeta(table: string): {
289
+ serverSet: string[];
290
+ readonly: string[];
291
+ broadcast: "consistent" | "eager" | "eager-durable";
292
+ pk: string;
293
+ } | undefined;
294
+ applyOptimistic(op: ClientOp): void;
295
+ clear(): Promise<void>;
296
+ }
297
+ interface SyncClientConfig {
298
+ clientId: string;
299
+ transport: ClientTransport;
300
+ token: string;
301
+ storage?: ClientStorageAdapter;
302
+ onSync?: (table: string) => void;
303
+ /**
304
+ * Structured error callback. `reason` is drawn from the protocol's
305
+ * ErrorReason taxonomy when the error originates server-side; local
306
+ * failures (reauth/transport) use a "local:*" namespace so consumers
307
+ * can switch on the string.
308
+ */
309
+ onError?: (error: {
310
+ opId?: string;
311
+ batchId?: string;
312
+ table?: string;
313
+ reason: ErrorReason | `local:${string}`;
314
+ message?: string;
315
+ }) => void;
316
+ onReauth?: () => Promise<string>;
317
+ /** Max reconnect backoff in ms (before jitter). Default: 30_000. */
318
+ maxReconnectDelayMs?: number;
319
+ /**
320
+ * Load every stored row at boot instead of only the tables this client is
321
+ * subscribed to. Needed only if you read rows for a table before calling
322
+ * `sync()` on it. Default: false.
323
+ */
324
+ hydrateAllTables?: boolean;
325
+ }
326
+ type SyncClientState = "hydrating" | "disconnected" | "connecting" | "connected" | "bootstrapping" | "synced";
327
+ interface SyncOptions {
328
+ window?: number;
329
+ }
330
+ declare class SyncClient {
331
+ private config;
332
+ private transport;
333
+ private opCreator;
334
+ private store;
335
+ private state;
336
+ /** Version agreed on in hello_ack. Null until first successful handshake. */
337
+ private negotiatedProtocolVersion;
338
+ private syncedTables;
339
+ private serverHlc;
340
+ private listeners;
341
+ private version;
342
+ private tableVersions;
343
+ private tableListeners;
344
+ private connectResolve;
345
+ private connectReject;
346
+ private connectInFlight;
347
+ private ephemeralListeners;
348
+ private totalCounts;
349
+ private syncOptions;
350
+ private bootstrapScheduled;
351
+ private initialized;
352
+ private syncParams;
353
+ private reconnectTimer;
354
+ private reconnectAttempts;
355
+ private closed;
356
+ /**
357
+ * Ops already transmitted, mapped to when they were sent. Without this,
358
+ * every mutation re-sends the whole pending set: 50 rapid edits before the
359
+ * first ack land as 1+2+…+50 = 1275 ops on the wire, each one a `reserveOp`
360
+ * insert server-side. Cleared on disconnect so a reconnect resends them.
361
+ */
362
+ private inFlightOps;
363
+ /**
364
+ * How long an op may sit unacked before a later push retries it. A frame can
365
+ * be dropped without the connection dying — a full server-side queue, for
366
+ * one — and without this the op would stay marked in-flight, never re-sent,
367
+ * until the connection happens to drop. Resends are idempotent server-side.
368
+ */
369
+ private static readonly IN_FLIGHT_TTL_MS;
370
+ /** Tail of the serialized push chain; also the coalescing slot. */
371
+ private pushChain;
372
+ private pushPending;
373
+ constructor(config: SyncClientConfig);
374
+ init(): Promise<void>;
375
+ connect(): Promise<void>;
376
+ close(): Promise<void>;
377
+ getState(): SyncClientState;
378
+ /** Version negotiated with the server. Null before the first hello_ack. */
379
+ getProtocolVersion(): number | null;
380
+ sync(table: string, params?: Record<string, unknown>, options?: SyncOptions): Promise<void>;
381
+ loadMore(table: string, count: number): Promise<void>;
382
+ getTotalCount(table: string): number | null;
383
+ unsync(table: string): Promise<void>;
384
+ bootstrap(): Promise<void>;
385
+ /**
386
+ * Schedule a bootstrap in the next microtask.
387
+ * Multiple calls within the same tick are coalesced into one bootstrap.
388
+ */
389
+ scheduleBootstrap(): void;
390
+ resume(): Promise<void>;
391
+ insert(table: string, rowId: string, payload: Record<string, unknown>): ClientOp;
392
+ update(table: string, rowId: string, payload: Record<string, unknown>): ClientOp;
393
+ delete(table: string, rowId: string): ClientOp;
394
+ batch(ops: Array<{
395
+ table: string;
396
+ op: OpType;
397
+ rowId: string;
398
+ payload: Record<string, unknown> | null;
399
+ }>): ClientOp[];
400
+ /**
401
+ * Transmit pending ops.
402
+ *
403
+ * Concurrent calls are coalesced: at most one push runs and at most one
404
+ * more is queued behind it, so a burst of mutations (every `useSync`
405
+ * mutation calls this) produces one send per op rather than one send of
406
+ * everything pending per mutation. The returned promise resolves once the
407
+ * caller's own ops have been handed to the transport.
408
+ */
409
+ push(): Promise<void>;
410
+ private doPush;
411
+ /** Forget which ops are on the wire — used when the connection drops. */
412
+ private clearInFlight;
413
+ sendEphemeral(params: {
414
+ key: string;
415
+ userId: string;
416
+ data: Record<string, unknown>;
417
+ ttlMs?: number;
418
+ }): Promise<void>;
419
+ subscribeEphemeral(key: string, listener: (event: EphemeralEvent) => void): () => void;
420
+ getRows(table: string, options?: {
421
+ includeDeleted?: boolean;
422
+ }): Record<string, unknown>[];
423
+ getRow(table: string, rowId: string): Record<string, unknown> | null;
424
+ getPendingCount(): number;
425
+ getStore(): ClientStore;
426
+ /**
427
+ * Subscribe to store changes. Returns an unsubscribe function.
428
+ * Compatible with React's useSyncExternalStore.
429
+ */
430
+ subscribe(listener: () => void): () => void;
431
+ /**
432
+ * Subscribe to changes for a specific table. Returns an unsubscribe function.
433
+ * More efficient than subscribe() — only fires when the given table changes.
434
+ */
435
+ subscribeTable(table: string, listener: () => void): () => void;
436
+ /** Returns a version number that increments on every data change. */
437
+ getVersion(): number;
438
+ /** Returns a version number for a specific table. Only increments when that table changes. */
439
+ getTableVersion(table: string): number;
440
+ private notify;
441
+ /**
442
+ * Reset client state. With no argument, wipes everything (pending ops,
443
+ * rows, server HLC, subscriptions). With `tables`, only those tables'
444
+ * rows + subscriptions are dropped — useful for logging out of a single
445
+ * scope or evicting a stale dataset without losing pending ops elsewhere.
446
+ */
447
+ clear(opts?: {
448
+ tables?: string[];
449
+ }): Promise<void>;
450
+ private handleMessage;
451
+ private mergeServerHlc;
452
+ private persistMeta;
453
+ private persistSyncSubscriptions;
454
+ private handleReauth;
455
+ private scheduleReconnect;
456
+ }
457
+ interface VanillaSyncConfig {
458
+ url: string;
459
+ token: string;
460
+ tables?: string[];
461
+ clientId?: string;
462
+ storage?: ClientStorageAdapter;
463
+ onReauth?: () => Promise<string>;
464
+ onError?: (error: {
465
+ opId?: string;
466
+ reason: string;
467
+ }) => void;
468
+ }
469
+ interface TableBinding<T = Record<string, unknown>> {
470
+ getRows(options?: {
471
+ includeDeleted?: boolean;
472
+ }): T[];
473
+ insert(rowId: string, payload: Partial<T>): void;
474
+ update(rowId: string, payload: Partial<T>): void;
475
+ remove(rowId: string): void;
476
+ onChange(cb: () => void): () => void;
477
+ destroy(): void;
478
+ }
479
+ interface EphemeralBinding<T = Record<string, unknown>> {
480
+ getEvents(): Record<string, T>;
481
+ broadcast(data: T): Promise<void>;
482
+ onChange(cb: (events: Record<string, T>) => void): () => void;
483
+ destroy(): void;
484
+ }
485
+ interface VanillaSync {
486
+ client: SyncClient;
487
+ connect(): Promise<void>;
488
+ close(): Promise<void>;
489
+ getState(): SyncClientState;
490
+ onStateChange(cb: (state: SyncClientState) => void): () => void;
491
+ getPendingCount(): number;
492
+ onPendingChange(cb: (count: number) => void): () => void;
493
+ sync<T extends object = Record<string, unknown>>(table: string, options?: {
494
+ params?: Record<string, unknown>;
495
+ includeDeleted?: boolean;
496
+ window?: number;
497
+ }): TableBinding<T>;
498
+ getRow<T extends object = Record<string, unknown>>(table: string, rowId: string): T | null;
499
+ onRowChange<T extends object = Record<string, unknown>>(table: string, rowId: string, cb: (row: T | null) => void): () => void;
500
+ ephemeral<T extends object>(config: {
501
+ key: string;
502
+ userId: string;
503
+ ttlMs?: number;
504
+ }): EphemeralBinding<T>;
505
+ getTotalCount(table: string): number | null;
506
+ onTotalCountChange(table: string, cb: (count: number | null) => void): () => void;
507
+ loadMore(table: string, count: number): void;
508
+ }
509
+ declare const REFLECT_SCHEME = "reflect:";
510
+ interface ReflectAction {
511
+ table: string;
512
+ rowId?: string;
513
+ params: URLSearchParams;
514
+ }
515
+ /**
516
+ * Parse a `reflect:<table>[/<rowId>][?<params>]` action.
517
+ *
518
+ * Returns null for anything that is not a reflect action, which is how the
519
+ * fetch shim decides whether to handle a request or let htmx hit the network.
520
+ *
521
+ * htmx rewrites GET/DELETE actions through `new URL(action, document.baseURI)`
522
+ * before the fetch runs. `reflect:` is a non-special scheme, so the URL parser
523
+ * keeps an opaque path and `href` round-trips unchanged (`reflect:todos` stays
524
+ * `reflect:todos`, query params land as `reflect:todos?a=b`). Parsing the raw
525
+ * string rather than going through `URL` keeps both spellings working.
526
+ */
527
+ declare function parseReflectAction(action: string): ReflectAction | null;
528
+ type ReflectOperation = {
529
+ kind: "read";
530
+ table: string;
531
+ rowId?: string;
532
+ params: URLSearchParams;
533
+ } | {
534
+ kind: "insert";
535
+ table: string;
536
+ rowId: string;
537
+ payload: Record<string, unknown>;
538
+ } | {
539
+ kind: "update";
540
+ table: string;
541
+ rowId: string;
542
+ payload: Record<string, unknown>;
543
+ } | {
544
+ kind: "remove";
545
+ table: string;
546
+ rowId: string;
547
+ } | {
548
+ kind: "error";
549
+ status: number;
550
+ message: string;
551
+ };
552
+ /** Anything htmx hands us as a request body: URLSearchParams or FormData. */
553
+ type ReflectBody = Iterable<[string, FormDataEntryValue]> | null | undefined;
554
+ interface ResolveOptions {
555
+ /** Used as the row id for POSTs that do not carry one. Injected for tests. */
556
+ generateRowId?: () => string;
557
+ }
558
+ /**
559
+ * Map an action + HTTP method + form body onto a store operation.
560
+ *
561
+ * The mapping is deliberately REST-shaped so the attributes read like ordinary
562
+ * htmx: `hx-get` reads, `hx-post` inserts, `hx-put`/`hx-patch` update,
563
+ * `hx-delete` removes.
564
+ */
565
+ declare function resolveOperation(action: string, method: string, body?: ReflectBody, options?: ResolveOptions): ReflectOperation | null;
566
+ /**
567
+ * Flatten a form body into a payload.
568
+ *
569
+ * Repeated keys collapse into an array so `<select multiple>` and checkbox
570
+ * groups survive. File entries are dropped: a File cannot cross the op log.
571
+ */
572
+ declare function collectPayload(body: ReflectBody): Record<string, unknown>;
573
+ /** The request context htmx hands to `htmx:config:request` listeners. */
574
+ interface HtmxRequestContext {
575
+ sourceElement: Element;
576
+ target?: Element;
577
+ swap?: string;
578
+ request: {
579
+ action: string;
580
+ method: string;
581
+ headers: Record<string, string>;
582
+ body?: ReflectBody;
583
+ };
584
+ /**
585
+ * htmx runs `ctx.fetch ||= window.fetch` after `htmx:config:request`, so a
586
+ * listener that assigns here owns the response and htmx still performs the
587
+ * swap, OOB handling, settling and history it normally would.
588
+ */
589
+ fetch?: (action: string, request: unknown) => Promise<Response>;
590
+ }
591
+ interface HtmxLike {
592
+ ajax(verb: string, path: string, options?: Record<string, unknown>): Promise<unknown>;
593
+ /** `metaCharacter` rewrites the `:` in htmx's own event names when set. */
594
+ config?: {
595
+ metaCharacter?: string;
596
+ };
597
+ }
598
+ interface ReflectViewInput<T> {
599
+ /** Every row for a collection read; the single row (or none) for a row read. */
600
+ rows: T[];
601
+ table: string;
602
+ /** Set when the action addressed one row: `reflect:todos/abc`. */
603
+ rowId?: string;
604
+ /** Query string of the action, for filtering and sorting inside the view. */
605
+ params: URLSearchParams;
606
+ }
607
+ /** Renders local rows to the HTML fragment htmx will swap in. */
608
+ type ReflectView<T = Record<string, unknown>> = (input: ReflectViewInput<T>) => string;
609
+ /** Coerces a form body before it reaches the op log (checkboxes, numbers, …). */
610
+ type ReflectParse<T = Record<string, unknown>> = (payload: Record<string, unknown>) => Partial<T>;
611
+ interface HtmxSyncConfig extends VanillaSyncConfig {
612
+ /** The htmx instance to attach to — import it yourself and pass it in. */
613
+ htmx: HtmxLike;
614
+ }
615
+ interface HtmxSync {
616
+ /** The underlying vanilla sync, for anything the attributes cannot express. */
617
+ sync: VanillaSync;
618
+ /** Register the renderer for a table. Required before any read of it. */
619
+ view<T extends object = Record<string, unknown>>(table: string, view: ReflectView<T>): HtmxSync;
620
+ /** Register an optional payload coercion for writes to a table. */
621
+ parse<T extends object = Record<string, unknown>>(table: string, parse: ReflectParse<T>): HtmxSync;
622
+ /** Attach the `reflect:` fetch shim to htmx. Idempotent. */
623
+ install(): HtmxSync;
624
+ /** Detach the shim and drop every element binding. */
625
+ uninstall(): void;
626
+ /** Re-render bound elements now — one table, or all of them. */
627
+ refresh(table?: string): void;
628
+ connect(): Promise<void>;
629
+ close(): Promise<void>;
630
+ getState(): SyncClientState;
631
+ onStateChange(cb: (state: SyncClientState) => void): () => void;
632
+ getPendingCount(): number;
633
+ onPendingChange(cb: (count: number) => void): () => void;
634
+ }
635
+ declare function createHtmxSync2(config: HtmxSyncConfig): HtmxSync;
636
+ interface DrizzleTableLike {
637
+ $inferSelect: Record<string, unknown>;
638
+ $inferInsert: Record<string, unknown>;
639
+ }
640
+ /**
641
+ * Value supplied per serverSet field in the schema's object form.
642
+ * Either a static value or a function that receives the request context.
643
+ *
644
+ * The static arm is enumerated rather than written as `unknown`: a union with
645
+ * `unknown` collapses to `unknown`, which strips the contextual type from the
646
+ * callback arm and makes every `(ctx) => …` an implicit `any` under `strict`.
647
+ */
648
+ type ServerSetSchemaValue = ((ctx: {
649
+ auth: unknown;
650
+ params: unknown;
651
+ }) => unknown) | string | number | bigint | boolean | symbol | null | undefined | Date | readonly unknown[] | Record<string, unknown>;
652
+ /**
653
+ * Schema-side `serverSet` declaration. Two shapes:
654
+ * - `string[]` — keys only; values are supplied at `implement(...)` time.
655
+ * - `Record<string, ServerSetSchemaValue>` — keys + values collocated in schema.
656
+ */
657
+ type SchemaServerSet = readonly string[] | Readonly<Record<string, ServerSetSchemaValue>>;
658
+ interface SyncQueryDef {
659
+ /** Drizzle table — row type derived from $inferSelect */
660
+ table?: DrizzleTableLike;
661
+ /** Phantom row type for non-drizzle usage: `{} as MyRow` */
662
+ row?: Record<string, unknown>;
663
+ /** Database tables for change detection. Falls back to query key name. */
664
+ tables?: string[];
665
+ /** Phantom value for params type: `{} as { orgId: string }` */
666
+ params?: Record<string, unknown>;
667
+ /** Primary key column name. Defaults to "id". Single-column only. */
668
+ pk?: string;
669
+ conflict?: ConflictPolicy;
670
+ readonly?: readonly string[];
671
+ serverSet?: SchemaServerSet;
672
+ countHints?: boolean;
673
+ }
674
+ /**
675
+ * Read-only computed query. Compiles down to `server.query(...)` with a
676
+ * throwing mutate so the type system blocks `useSync(...).insert/update/remove`
677
+ * and the runtime rejects any direct write attempt.
678
+ */
679
+ interface SyncViewDef {
680
+ __view: true;
681
+ row?: Record<string, unknown>;
682
+ params?: Record<string, unknown>;
683
+ tables?: string[];
684
+ /** Tables to watch for change-detection. Defaults to `tables` or query key. */
685
+ deps?: string[];
686
+ }
687
+ /**
688
+ * Typed ephemeral channel. Sugar over the runtime `useEphemeral` hook —
689
+ * derives a stable key from the schema name + serialized params and gives
690
+ * `peers`/`set` a typed `state`.
691
+ */
692
+ interface SyncPresenceDef {
693
+ __presence: true;
694
+ state?: Record<string, unknown>;
695
+ params?: Record<string, unknown>;
696
+ ttlMs?: number;
697
+ }
698
+ type SyncQueryEntry = SyncQueryDef | SyncViewDef | SyncPresenceDef;
699
+ type SyncQueryMap = Record<string, SyncQueryEntry>;
700
+ /** Row type: from `table.$inferSelect` if drizzle, otherwise from `row` phantom */
701
+ type InferRow<
702
+ TQueries extends SyncQueryMap,
703
+ K extends keyof TQueries
704
+ > = TQueries[K] extends {
705
+ table: DrizzleTableLike;
706
+ } ? TQueries[K]["table"]["$inferSelect"] : TQueries[K] extends {
707
+ row: infer R extends Record<string, unknown>;
708
+ } ? R : Record<string, unknown>;
709
+ /** Primary key column name. Defaults to "id". */
710
+ type PkOf<TDef> = TDef extends {
711
+ pk: infer P extends string;
712
+ } ? P : "id";
713
+ /** Extract serverSet field names from either array form or object form. */
714
+ type InferServerSetKeys<TDef> = TDef extends {
715
+ serverSet: readonly (infer S extends string)[];
716
+ } ? S : TDef extends {
717
+ serverSet: infer O extends Readonly<Record<string, unknown>>;
718
+ } ? keyof O & string : never;
719
+ /**
720
+ * Row minus readonly fields, serverSet fields, and the primary key.
721
+ * View defs collapse to `never` to block client-side writes at the type level.
722
+ * Presence defs aren't writable rows; they collapse to `never` too.
723
+ */
724
+ type InferWritableRow<
725
+ TQueries extends SyncQueryMap,
726
+ K extends keyof TQueries
727
+ > = TQueries[K] extends {
728
+ __view: true;
729
+ } | {
730
+ __presence: true;
731
+ } ? never : Omit<InferRow<TQueries, K>, (TQueries[K] extends {
732
+ readonly: readonly (infer R extends string)[];
733
+ } ? R : never) | InferServerSetKeys<TQueries[K]> | PkOf<TQueries[K]>>;
734
+ type TypedReflectView<
735
+ TQueries extends SyncQueryMap,
736
+ K extends keyof TQueries
737
+ > = (input: ReflectViewInput<InferRow<TQueries, K>>) => string;
738
+ type TypedReflectParse<
739
+ TQueries extends SyncQueryMap,
740
+ K extends keyof TQueries
741
+ > = (payload: Record<string, unknown>) => Partial<InferWritableRow<TQueries, K>>;
742
+ interface TypedHtmxSync<TQueries extends SyncQueryMap> {
743
+ sync: VanillaSync;
744
+ view<K extends keyof TQueries & string>(table: K, view: TypedReflectView<TQueries, K>): TypedHtmxSync<TQueries>;
745
+ parse<K extends keyof TQueries & string>(table: K, parse: TypedReflectParse<TQueries, K>): TypedHtmxSync<TQueries>;
746
+ install(): TypedHtmxSync<TQueries>;
747
+ uninstall(): void;
748
+ refresh<K extends keyof TQueries & string>(table?: K): void;
749
+ connect(): Promise<void>;
750
+ close(): Promise<void>;
751
+ getState(): SyncClientState;
752
+ onStateChange(cb: (state: SyncClientState) => void): () => void;
753
+ getPendingCount(): number;
754
+ onPendingChange(cb: (count: number) => void): () => void;
755
+ }
756
+ interface SyncHtmxHooks<TQueries extends SyncQueryMap> {
757
+ createHtmxSync: (config: HtmxSyncConfig) => TypedHtmxSync<TQueries>;
758
+ }
759
+ declare function createSyncHtmx<TQueries extends SyncQueryMap>(): SyncHtmxHooks<TQueries>;
760
+ export { HtmxLike, HtmxRequestContext, HtmxSync, HtmxSyncConfig, REFLECT_SCHEME, ReflectAction, ReflectBody, ReflectOperation, ReflectParse, ReflectView, ReflectViewInput, ResolveOptions, SyncHtmxHooks, TypedHtmxSync, TypedReflectParse, TypedReflectView, collectPayload, createHtmxSync2 as createHtmxSync, createSyncHtmx, parseReflectAction, resolveOperation };