@taladb/next 0.9.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 thinkgrid-labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,44 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ReactNode } from 'react';
3
+ import { SyncOptions, SyncResult } from 'taladb';
4
+
5
+ interface SyncProviderProps {
6
+ /** Base URL of your sync backend, e.g. `/api/sync` (same-origin route
7
+ * handler created with `createSyncHandlers`) or a full URL. */
8
+ endpoint: string;
9
+ /** Milliseconds between passes while the tab is open. Default 30 000. Set
10
+ * `0` to disable the interval (event-driven passes still run). */
11
+ intervalMs?: number;
12
+ /** Headers for every request — typically `Authorization`. Pass a function
13
+ * to read a fresh token per pass. */
14
+ headers?: Record<string, string> | (() => Record<string, string>);
15
+ /** Scope/direction options forwarded to every `db.sync()` pass. */
16
+ options?: SyncOptions;
17
+ /** Called after each successful pass. */
18
+ onSync?: (result: SyncResult) => void;
19
+ /** Called when a pass fails (offline, 401, server down). The next replayed
20
+ * pass covers the gap. */
21
+ onError?: (error: unknown) => void;
22
+ children?: ReactNode;
23
+ }
24
+ /**
25
+ * ```tsx
26
+ * // app/providers.tsx
27
+ * 'use client'
28
+ * import { TalaDBProvider } from '@taladb/react'
29
+ * import { SyncProvider } from '@taladb/next/client'
30
+ *
31
+ * export function Providers({ children }) {
32
+ * return (
33
+ * <TalaDBProvider name="myapp.db">
34
+ * <SyncProvider endpoint="/api/sync" headers={() => ({ Authorization: `Bearer ${getToken()}` })}>
35
+ * {children}
36
+ * </SyncProvider>
37
+ * </TalaDBProvider>
38
+ * )
39
+ * }
40
+ * ```
41
+ */
42
+ declare function SyncProvider({ endpoint, intervalMs, headers, options, onSync, onError, children, }: SyncProviderProps): react_jsx_runtime.JSX.Element;
43
+
44
+ export { SyncProvider, type SyncProviderProps };
@@ -0,0 +1,44 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ReactNode } from 'react';
3
+ import { SyncOptions, SyncResult } from 'taladb';
4
+
5
+ interface SyncProviderProps {
6
+ /** Base URL of your sync backend, e.g. `/api/sync` (same-origin route
7
+ * handler created with `createSyncHandlers`) or a full URL. */
8
+ endpoint: string;
9
+ /** Milliseconds between passes while the tab is open. Default 30 000. Set
10
+ * `0` to disable the interval (event-driven passes still run). */
11
+ intervalMs?: number;
12
+ /** Headers for every request — typically `Authorization`. Pass a function
13
+ * to read a fresh token per pass. */
14
+ headers?: Record<string, string> | (() => Record<string, string>);
15
+ /** Scope/direction options forwarded to every `db.sync()` pass. */
16
+ options?: SyncOptions;
17
+ /** Called after each successful pass. */
18
+ onSync?: (result: SyncResult) => void;
19
+ /** Called when a pass fails (offline, 401, server down). The next replayed
20
+ * pass covers the gap. */
21
+ onError?: (error: unknown) => void;
22
+ children?: ReactNode;
23
+ }
24
+ /**
25
+ * ```tsx
26
+ * // app/providers.tsx
27
+ * 'use client'
28
+ * import { TalaDBProvider } from '@taladb/react'
29
+ * import { SyncProvider } from '@taladb/next/client'
30
+ *
31
+ * export function Providers({ children }) {
32
+ * return (
33
+ * <TalaDBProvider name="myapp.db">
34
+ * <SyncProvider endpoint="/api/sync" headers={() => ({ Authorization: `Bearer ${getToken()}` })}>
35
+ * {children}
36
+ * </SyncProvider>
37
+ * </TalaDBProvider>
38
+ * )
39
+ * }
40
+ * ```
41
+ */
42
+ declare function SyncProvider({ endpoint, intervalMs, headers, options, onSync, onError, children, }: SyncProviderProps): react_jsx_runtime.JSX.Element;
43
+
44
+ export { SyncProvider, type SyncProviderProps };
package/dist/client.js ADDED
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __export = (target, all) => {
10
+ for (var name in all)
11
+ __defProp(target, name, { get: all[name], enumerable: true });
12
+ };
13
+ var __copyProps = (to, from, except, desc) => {
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (let key of __getOwnPropNames(from))
16
+ if (!__hasOwnProp.call(to, key) && key !== except)
17
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
18
+ }
19
+ return to;
20
+ };
21
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
22
+ // If the importer is in node compatibility mode or this is not an ESM
23
+ // file that has been converted to a CommonJS file using a Babel-
24
+ // compatible transform (i.e. "__esModule" has not been set), then set
25
+ // "default" to the CommonJS "module.exports" for node compatibility.
26
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
27
+ mod
28
+ ));
29
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
30
+
31
+ // src/client.tsx
32
+ var client_exports = {};
33
+ __export(client_exports, {
34
+ SyncProvider: () => SyncProvider
35
+ });
36
+ module.exports = __toCommonJS(client_exports);
37
+ var import_react = require("react");
38
+ var import_react2 = require("@taladb/react");
39
+ var import_jsx_runtime = require("react/jsx-runtime");
40
+ function SyncProvider({
41
+ endpoint,
42
+ intervalMs = 3e4,
43
+ headers,
44
+ options,
45
+ onSync,
46
+ onError,
47
+ children
48
+ }) {
49
+ const db = (0, import_react2.useTalaDB)();
50
+ const latest = (0, import_react.useRef)({ headers, options, onSync, onError });
51
+ latest.current = { headers, options, onSync, onError };
52
+ const activePass = (0, import_react.useRef)(null);
53
+ (0, import_react.useEffect)(() => {
54
+ let stopped = false;
55
+ let waitingForActive = false;
56
+ const sync = async () => {
57
+ if (stopped) return;
58
+ if (activePass.current) {
59
+ if (waitingForActive) return;
60
+ waitingForActive = true;
61
+ await activePass.current;
62
+ waitingForActive = false;
63
+ if (!stopped) void sync();
64
+ return;
65
+ }
66
+ const pass = Promise.resolve().then(async () => {
67
+ try {
68
+ const { HttpSyncAdapter } = await import("taladb");
69
+ const h = latest.current.headers;
70
+ const adapter = new HttpSyncAdapter({
71
+ endpoint,
72
+ headers: typeof h === "function" ? h() : h
73
+ });
74
+ const result = await db.sync(adapter, latest.current.options ?? {});
75
+ if (!stopped) latest.current.onSync?.(result);
76
+ } catch (e) {
77
+ if (!stopped) latest.current.onError?.(e);
78
+ }
79
+ });
80
+ activePass.current = pass;
81
+ await pass.finally(() => {
82
+ if (activePass.current === pass) activePass.current = null;
83
+ });
84
+ };
85
+ void sync();
86
+ const tick = intervalMs > 0 ? setInterval(sync, intervalMs) : void 0;
87
+ const onOnline = () => void sync();
88
+ const onVisible = () => {
89
+ if (document.visibilityState === "visible") void sync();
90
+ };
91
+ window.addEventListener("online", onOnline);
92
+ document.addEventListener("visibilitychange", onVisible);
93
+ return () => {
94
+ stopped = true;
95
+ if (tick !== void 0) clearInterval(tick);
96
+ window.removeEventListener("online", onOnline);
97
+ document.removeEventListener("visibilitychange", onVisible);
98
+ };
99
+ }, [db, endpoint, intervalMs]);
100
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_jsx_runtime.Fragment, { children });
101
+ }
102
+ // Annotate the CommonJS export names for ESM import in node:
103
+ 0 && (module.exports = {
104
+ SyncProvider
105
+ });
@@ -0,0 +1,71 @@
1
+ 'use client';
2
+
3
+ // src/client.tsx
4
+ import { useEffect, useRef } from "react";
5
+ import { useTalaDB } from "@taladb/react";
6
+ import { Fragment, jsx } from "react/jsx-runtime";
7
+ function SyncProvider({
8
+ endpoint,
9
+ intervalMs = 3e4,
10
+ headers,
11
+ options,
12
+ onSync,
13
+ onError,
14
+ children
15
+ }) {
16
+ const db = useTalaDB();
17
+ const latest = useRef({ headers, options, onSync, onError });
18
+ latest.current = { headers, options, onSync, onError };
19
+ const activePass = useRef(null);
20
+ useEffect(() => {
21
+ let stopped = false;
22
+ let waitingForActive = false;
23
+ const sync = async () => {
24
+ if (stopped) return;
25
+ if (activePass.current) {
26
+ if (waitingForActive) return;
27
+ waitingForActive = true;
28
+ await activePass.current;
29
+ waitingForActive = false;
30
+ if (!stopped) void sync();
31
+ return;
32
+ }
33
+ const pass = Promise.resolve().then(async () => {
34
+ try {
35
+ const { HttpSyncAdapter } = await import("taladb");
36
+ const h = latest.current.headers;
37
+ const adapter = new HttpSyncAdapter({
38
+ endpoint,
39
+ headers: typeof h === "function" ? h() : h
40
+ });
41
+ const result = await db.sync(adapter, latest.current.options ?? {});
42
+ if (!stopped) latest.current.onSync?.(result);
43
+ } catch (e) {
44
+ if (!stopped) latest.current.onError?.(e);
45
+ }
46
+ });
47
+ activePass.current = pass;
48
+ await pass.finally(() => {
49
+ if (activePass.current === pass) activePass.current = null;
50
+ });
51
+ };
52
+ void sync();
53
+ const tick = intervalMs > 0 ? setInterval(sync, intervalMs) : void 0;
54
+ const onOnline = () => void sync();
55
+ const onVisible = () => {
56
+ if (document.visibilityState === "visible") void sync();
57
+ };
58
+ window.addEventListener("online", onOnline);
59
+ document.addEventListener("visibilitychange", onVisible);
60
+ return () => {
61
+ stopped = true;
62
+ if (tick !== void 0) clearInterval(tick);
63
+ window.removeEventListener("online", onOnline);
64
+ document.removeEventListener("visibilitychange", onVisible);
65
+ };
66
+ }, [db, endpoint, intervalMs]);
67
+ return /* @__PURE__ */ jsx(Fragment, { children });
68
+ }
69
+ export {
70
+ SyncProvider
71
+ };
@@ -0,0 +1,85 @@
1
+ import { TalaDB } from 'taladb';
2
+
3
+ /** The fields of a TalaDB change record the store keys on. Everything else in
4
+ * a record is opaque — store it verbatim. */
5
+ interface ChangeRecord {
6
+ collection: string;
7
+ id: string;
8
+ changed_at: number;
9
+ [key: string]: unknown;
10
+ }
11
+ /**
12
+ * Where pushed changes live. Implementations keep the **latest change per
13
+ * document per scope** (Last-Write-Wins by `changed_at`) and answer
14
+ * "everything in this scope newer than `sinceMs`".
15
+ *
16
+ * `scope` partitions users or workspaces — the value returned by
17
+ * `authorize`. A store must never leak changes across scopes.
18
+ */
19
+ interface SyncStore {
20
+ /** Merge a pushed changeset (JSON string of `ChangeRecord[]`) into `scope`. */
21
+ push(changeset: string, scope: string): Promise<void>;
22
+ /** Serialized changeset of `scope` changes with `changed_at > sinceMs`. */
23
+ pull(sinceMs: number, scope: string): Promise<string>;
24
+ }
25
+ interface CreateSyncHandlersOptions {
26
+ store: SyncStore;
27
+ /** Maximum accepted push body size in bytes. Default 1 MiB. */
28
+ maxBodyBytes?: number;
29
+ /** Maximum records accepted in one push. Default 10,000. */
30
+ maxRecords?: number;
31
+ /**
32
+ * Identify and authorize the caller, returning a scope key (e.g. the user
33
+ * id) — this is your security boundary. Return `null`/`undefined` to reject
34
+ * with 401. Omit for a single shared scope (`'default'`) — fine for
35
+ * prototypes and single-user apps, wrong for anything multi-user.
36
+ */
37
+ authorize?: (req: Request) => Promise<string | null | undefined> | string | null | undefined;
38
+ }
39
+ interface SyncHandlers {
40
+ /** Mount as the handler for `POST {endpoint}/push`. */
41
+ POST(req: Request): Promise<Response>;
42
+ /** Mount as the handler for `GET {endpoint}/pull`. */
43
+ GET(req: Request): Promise<Response>;
44
+ }
45
+ /**
46
+ * A complete sync backend as a pair of fetch-style route handlers.
47
+ *
48
+ * ```ts
49
+ * // app/api/sync/[[...action]]/route.ts
50
+ * import { createSyncHandlers, memorySyncStore } from '@taladb/next/server'
51
+ *
52
+ * export const { POST, GET } = createSyncHandlers({
53
+ * store: memorySyncStore(),
54
+ * authorize: async (req) => verifySession(req.headers.get('authorization')),
55
+ * })
56
+ * ```
57
+ *
58
+ * Point the client's `HttpSyncAdapter` at `endpoint: '/api/sync'`.
59
+ */
60
+ declare function createSyncHandlers(options: CreateSyncHandlersOptions): SyncHandlers;
61
+ /**
62
+ * In-memory store — perfect for development and tests; state is lost on
63
+ * server restart and not shared across serverless instances. Use
64
+ * {@link taladbSyncStore} (or your own database) in production.
65
+ */
66
+ declare function memorySyncStore(): SyncStore;
67
+ /**
68
+ * A store backed by a server-side TalaDB — TalaDB syncing to TalaDB. Open a
69
+ * file-backed database once (Node.js runtime, not edge) and hand it in:
70
+ *
71
+ * ```ts
72
+ * import { openDB } from 'taladb'
73
+ * import { createSyncHandlers, taladbSyncStore } from '@taladb/next/server'
74
+ *
75
+ * const serverDb = await openDB('sync-hub.db')
76
+ * export const { POST, GET } = createSyncHandlers({ store: taladbSyncStore(serverDb) })
77
+ * ```
78
+ *
79
+ * Stores the greatest timestamp per synced document and preserves distinct
80
+ * equal-timestamp candidates so TalaDB core can apply its exact deterministic
81
+ * tie-break when clients import them.
82
+ */
83
+ declare function taladbSyncStore(db: TalaDB, collectionName?: string): SyncStore;
84
+
85
+ export { type ChangeRecord, type CreateSyncHandlersOptions, type SyncHandlers, type SyncStore, createSyncHandlers, memorySyncStore, taladbSyncStore };
@@ -0,0 +1,85 @@
1
+ import { TalaDB } from 'taladb';
2
+
3
+ /** The fields of a TalaDB change record the store keys on. Everything else in
4
+ * a record is opaque — store it verbatim. */
5
+ interface ChangeRecord {
6
+ collection: string;
7
+ id: string;
8
+ changed_at: number;
9
+ [key: string]: unknown;
10
+ }
11
+ /**
12
+ * Where pushed changes live. Implementations keep the **latest change per
13
+ * document per scope** (Last-Write-Wins by `changed_at`) and answer
14
+ * "everything in this scope newer than `sinceMs`".
15
+ *
16
+ * `scope` partitions users or workspaces — the value returned by
17
+ * `authorize`. A store must never leak changes across scopes.
18
+ */
19
+ interface SyncStore {
20
+ /** Merge a pushed changeset (JSON string of `ChangeRecord[]`) into `scope`. */
21
+ push(changeset: string, scope: string): Promise<void>;
22
+ /** Serialized changeset of `scope` changes with `changed_at > sinceMs`. */
23
+ pull(sinceMs: number, scope: string): Promise<string>;
24
+ }
25
+ interface CreateSyncHandlersOptions {
26
+ store: SyncStore;
27
+ /** Maximum accepted push body size in bytes. Default 1 MiB. */
28
+ maxBodyBytes?: number;
29
+ /** Maximum records accepted in one push. Default 10,000. */
30
+ maxRecords?: number;
31
+ /**
32
+ * Identify and authorize the caller, returning a scope key (e.g. the user
33
+ * id) — this is your security boundary. Return `null`/`undefined` to reject
34
+ * with 401. Omit for a single shared scope (`'default'`) — fine for
35
+ * prototypes and single-user apps, wrong for anything multi-user.
36
+ */
37
+ authorize?: (req: Request) => Promise<string | null | undefined> | string | null | undefined;
38
+ }
39
+ interface SyncHandlers {
40
+ /** Mount as the handler for `POST {endpoint}/push`. */
41
+ POST(req: Request): Promise<Response>;
42
+ /** Mount as the handler for `GET {endpoint}/pull`. */
43
+ GET(req: Request): Promise<Response>;
44
+ }
45
+ /**
46
+ * A complete sync backend as a pair of fetch-style route handlers.
47
+ *
48
+ * ```ts
49
+ * // app/api/sync/[[...action]]/route.ts
50
+ * import { createSyncHandlers, memorySyncStore } from '@taladb/next/server'
51
+ *
52
+ * export const { POST, GET } = createSyncHandlers({
53
+ * store: memorySyncStore(),
54
+ * authorize: async (req) => verifySession(req.headers.get('authorization')),
55
+ * })
56
+ * ```
57
+ *
58
+ * Point the client's `HttpSyncAdapter` at `endpoint: '/api/sync'`.
59
+ */
60
+ declare function createSyncHandlers(options: CreateSyncHandlersOptions): SyncHandlers;
61
+ /**
62
+ * In-memory store — perfect for development and tests; state is lost on
63
+ * server restart and not shared across serverless instances. Use
64
+ * {@link taladbSyncStore} (or your own database) in production.
65
+ */
66
+ declare function memorySyncStore(): SyncStore;
67
+ /**
68
+ * A store backed by a server-side TalaDB — TalaDB syncing to TalaDB. Open a
69
+ * file-backed database once (Node.js runtime, not edge) and hand it in:
70
+ *
71
+ * ```ts
72
+ * import { openDB } from 'taladb'
73
+ * import { createSyncHandlers, taladbSyncStore } from '@taladb/next/server'
74
+ *
75
+ * const serverDb = await openDB('sync-hub.db')
76
+ * export const { POST, GET } = createSyncHandlers({ store: taladbSyncStore(serverDb) })
77
+ * ```
78
+ *
79
+ * Stores the greatest timestamp per synced document and preserves distinct
80
+ * equal-timestamp candidates so TalaDB core can apply its exact deterministic
81
+ * tie-break when clients import them.
82
+ */
83
+ declare function taladbSyncStore(db: TalaDB, collectionName?: string): SyncStore;
84
+
85
+ export { type ChangeRecord, type CreateSyncHandlersOptions, type SyncHandlers, type SyncStore, createSyncHandlers, memorySyncStore, taladbSyncStore };
package/dist/server.js ADDED
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/server.ts
21
+ var server_exports = {};
22
+ __export(server_exports, {
23
+ createSyncHandlers: () => createSyncHandlers,
24
+ memorySyncStore: () => memorySyncStore,
25
+ taladbSyncStore: () => taladbSyncStore
26
+ });
27
+ module.exports = __toCommonJS(server_exports);
28
+ function createSyncHandlers(options) {
29
+ const { store } = options;
30
+ const maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024;
31
+ const maxRecords = options.maxRecords ?? 1e4;
32
+ const authorize = options.authorize ?? (() => "default");
33
+ async function resolveScope(req) {
34
+ const scope = await authorize(req);
35
+ return scope ?? null;
36
+ }
37
+ return {
38
+ async POST(req) {
39
+ const scope = await resolveScope(req);
40
+ if (scope === null) return new Response("unauthorized", { status: 401 });
41
+ const declaredLength = Number(req.headers.get("content-length"));
42
+ if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
43
+ return new Response("changeset too large", { status: 413 });
44
+ }
45
+ const body = await req.text();
46
+ if (new TextEncoder().encode(body).byteLength > maxBodyBytes) {
47
+ return new Response("changeset too large", { status: 413 });
48
+ }
49
+ let records;
50
+ try {
51
+ records = JSON.parse(body === "" ? "[]" : body);
52
+ } catch {
53
+ return new Response("invalid changeset: not JSON", { status: 400 });
54
+ }
55
+ if (!Array.isArray(records)) {
56
+ return new Response("invalid changeset: expected a JSON array", { status: 400 });
57
+ }
58
+ if (records.length > maxRecords) {
59
+ return new Response("too many change records", { status: 413 });
60
+ }
61
+ for (const r of records) {
62
+ const rec = r;
63
+ if (typeof rec !== "object" || rec === null || typeof rec.collection !== "string" || typeof rec.id !== "string" || typeof rec.changed_at !== "number" || !Number.isSafeInteger(rec.changed_at) || rec.changed_at < 0) {
64
+ return new Response("invalid changeset: malformed change record", { status: 400 });
65
+ }
66
+ }
67
+ if (records.length > 0) {
68
+ await store.push(JSON.stringify(records), scope);
69
+ }
70
+ return new Response(null, { status: 204 });
71
+ },
72
+ async GET(req) {
73
+ const scope = await resolveScope(req);
74
+ if (scope === null) return new Response("unauthorized", { status: 401 });
75
+ const sinceRaw = new URL(req.url).searchParams.get("since") ?? "0";
76
+ const since = Number(sinceRaw);
77
+ if (!Number.isSafeInteger(since) || since < 0) {
78
+ return new Response("invalid since parameter", { status: 400 });
79
+ }
80
+ const changeset = await store.pull(since, scope);
81
+ return new Response(changeset, {
82
+ status: 200,
83
+ headers: { "content-type": "application/json" }
84
+ });
85
+ }
86
+ };
87
+ }
88
+ function memorySyncStore() {
89
+ const scopes = /* @__PURE__ */ new Map();
90
+ return {
91
+ async push(changeset, scope) {
92
+ let docs = scopes.get(scope);
93
+ if (!docs) {
94
+ docs = /* @__PURE__ */ new Map();
95
+ scopes.set(scope, docs);
96
+ }
97
+ for (const change of JSON.parse(changeset)) {
98
+ const key = `${change.collection}::${change.id}`;
99
+ const existing = docs.get(key) ?? [];
100
+ const latest = existing[0]?.changed_at;
101
+ if (latest === void 0 || change.changed_at > latest) docs.set(key, [change]);
102
+ else if (change.changed_at === latest) {
103
+ const serialized = JSON.stringify(change);
104
+ if (!existing.some((candidate) => JSON.stringify(candidate) === serialized)) {
105
+ existing.push(change);
106
+ }
107
+ }
108
+ }
109
+ },
110
+ async pull(sinceMs, scope) {
111
+ const docs = scopes.get(scope);
112
+ if (!docs) return "[]";
113
+ return JSON.stringify(
114
+ [...docs.values()].flat().filter((c) => c.changed_at > sinceMs)
115
+ );
116
+ }
117
+ };
118
+ }
119
+ var taladbStoreQueues = /* @__PURE__ */ new WeakMap();
120
+ function taladbSyncStore(db, collectionName = "sync_changes") {
121
+ const col = db.collection(collectionName);
122
+ const indexed = (async () => {
123
+ await col.createIndex("doc_key").catch(() => {
124
+ });
125
+ await col.createIndex("changed_at").catch(() => {
126
+ });
127
+ })();
128
+ let queues = taladbStoreQueues.get(db);
129
+ if (!queues) {
130
+ queues = /* @__PURE__ */ new Map();
131
+ taladbStoreQueues.set(db, queues);
132
+ }
133
+ return {
134
+ async push(changeset, scope) {
135
+ const run = async () => {
136
+ await indexed;
137
+ for (const change of JSON.parse(changeset)) {
138
+ const docKey = `${change.collection}::${change.id}`;
139
+ const serialized = JSON.stringify(change);
140
+ const existing = await col.find({ scope, doc_key: docKey });
141
+ const latest = existing.reduce((n, row) => Math.max(n, row.changed_at), -Infinity);
142
+ if (change.changed_at > latest) {
143
+ if (existing.length > 0) await col.deleteMany({ scope, doc_key: docKey });
144
+ await col.insert({ scope, doc_key: docKey, changed_at: change.changed_at, change: serialized });
145
+ } else if (change.changed_at === latest && !existing.some((row) => row.change === serialized)) {
146
+ await col.insert({ scope, doc_key: docKey, changed_at: change.changed_at, change: serialized });
147
+ }
148
+ }
149
+ };
150
+ const previous = queues.get(collectionName) ?? Promise.resolve();
151
+ const operation = previous.then(run, run);
152
+ queues.set(collectionName, operation.catch(() => {
153
+ }));
154
+ await operation;
155
+ },
156
+ async pull(sinceMs, scope) {
157
+ await indexed;
158
+ const rows = await col.find({ scope, changed_at: { $gt: sinceMs } });
159
+ return `[${rows.map((r) => r.change).join(",")}]`;
160
+ }
161
+ };
162
+ }
163
+ // Annotate the CommonJS export names for ESM import in node:
164
+ 0 && (module.exports = {
165
+ createSyncHandlers,
166
+ memorySyncStore,
167
+ taladbSyncStore
168
+ });
@@ -0,0 +1,141 @@
1
+ // src/server.ts
2
+ function createSyncHandlers(options) {
3
+ const { store } = options;
4
+ const maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024;
5
+ const maxRecords = options.maxRecords ?? 1e4;
6
+ const authorize = options.authorize ?? (() => "default");
7
+ async function resolveScope(req) {
8
+ const scope = await authorize(req);
9
+ return scope ?? null;
10
+ }
11
+ return {
12
+ async POST(req) {
13
+ const scope = await resolveScope(req);
14
+ if (scope === null) return new Response("unauthorized", { status: 401 });
15
+ const declaredLength = Number(req.headers.get("content-length"));
16
+ if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
17
+ return new Response("changeset too large", { status: 413 });
18
+ }
19
+ const body = await req.text();
20
+ if (new TextEncoder().encode(body).byteLength > maxBodyBytes) {
21
+ return new Response("changeset too large", { status: 413 });
22
+ }
23
+ let records;
24
+ try {
25
+ records = JSON.parse(body === "" ? "[]" : body);
26
+ } catch {
27
+ return new Response("invalid changeset: not JSON", { status: 400 });
28
+ }
29
+ if (!Array.isArray(records)) {
30
+ return new Response("invalid changeset: expected a JSON array", { status: 400 });
31
+ }
32
+ if (records.length > maxRecords) {
33
+ return new Response("too many change records", { status: 413 });
34
+ }
35
+ for (const r of records) {
36
+ const rec = r;
37
+ if (typeof rec !== "object" || rec === null || typeof rec.collection !== "string" || typeof rec.id !== "string" || typeof rec.changed_at !== "number" || !Number.isSafeInteger(rec.changed_at) || rec.changed_at < 0) {
38
+ return new Response("invalid changeset: malformed change record", { status: 400 });
39
+ }
40
+ }
41
+ if (records.length > 0) {
42
+ await store.push(JSON.stringify(records), scope);
43
+ }
44
+ return new Response(null, { status: 204 });
45
+ },
46
+ async GET(req) {
47
+ const scope = await resolveScope(req);
48
+ if (scope === null) return new Response("unauthorized", { status: 401 });
49
+ const sinceRaw = new URL(req.url).searchParams.get("since") ?? "0";
50
+ const since = Number(sinceRaw);
51
+ if (!Number.isSafeInteger(since) || since < 0) {
52
+ return new Response("invalid since parameter", { status: 400 });
53
+ }
54
+ const changeset = await store.pull(since, scope);
55
+ return new Response(changeset, {
56
+ status: 200,
57
+ headers: { "content-type": "application/json" }
58
+ });
59
+ }
60
+ };
61
+ }
62
+ function memorySyncStore() {
63
+ const scopes = /* @__PURE__ */ new Map();
64
+ return {
65
+ async push(changeset, scope) {
66
+ let docs = scopes.get(scope);
67
+ if (!docs) {
68
+ docs = /* @__PURE__ */ new Map();
69
+ scopes.set(scope, docs);
70
+ }
71
+ for (const change of JSON.parse(changeset)) {
72
+ const key = `${change.collection}::${change.id}`;
73
+ const existing = docs.get(key) ?? [];
74
+ const latest = existing[0]?.changed_at;
75
+ if (latest === void 0 || change.changed_at > latest) docs.set(key, [change]);
76
+ else if (change.changed_at === latest) {
77
+ const serialized = JSON.stringify(change);
78
+ if (!existing.some((candidate) => JSON.stringify(candidate) === serialized)) {
79
+ existing.push(change);
80
+ }
81
+ }
82
+ }
83
+ },
84
+ async pull(sinceMs, scope) {
85
+ const docs = scopes.get(scope);
86
+ if (!docs) return "[]";
87
+ return JSON.stringify(
88
+ [...docs.values()].flat().filter((c) => c.changed_at > sinceMs)
89
+ );
90
+ }
91
+ };
92
+ }
93
+ var taladbStoreQueues = /* @__PURE__ */ new WeakMap();
94
+ function taladbSyncStore(db, collectionName = "sync_changes") {
95
+ const col = db.collection(collectionName);
96
+ const indexed = (async () => {
97
+ await col.createIndex("doc_key").catch(() => {
98
+ });
99
+ await col.createIndex("changed_at").catch(() => {
100
+ });
101
+ })();
102
+ let queues = taladbStoreQueues.get(db);
103
+ if (!queues) {
104
+ queues = /* @__PURE__ */ new Map();
105
+ taladbStoreQueues.set(db, queues);
106
+ }
107
+ return {
108
+ async push(changeset, scope) {
109
+ const run = async () => {
110
+ await indexed;
111
+ for (const change of JSON.parse(changeset)) {
112
+ const docKey = `${change.collection}::${change.id}`;
113
+ const serialized = JSON.stringify(change);
114
+ const existing = await col.find({ scope, doc_key: docKey });
115
+ const latest = existing.reduce((n, row) => Math.max(n, row.changed_at), -Infinity);
116
+ if (change.changed_at > latest) {
117
+ if (existing.length > 0) await col.deleteMany({ scope, doc_key: docKey });
118
+ await col.insert({ scope, doc_key: docKey, changed_at: change.changed_at, change: serialized });
119
+ } else if (change.changed_at === latest && !existing.some((row) => row.change === serialized)) {
120
+ await col.insert({ scope, doc_key: docKey, changed_at: change.changed_at, change: serialized });
121
+ }
122
+ }
123
+ };
124
+ const previous = queues.get(collectionName) ?? Promise.resolve();
125
+ const operation = previous.then(run, run);
126
+ queues.set(collectionName, operation.catch(() => {
127
+ }));
128
+ await operation;
129
+ },
130
+ async pull(sinceMs, scope) {
131
+ await indexed;
132
+ const rows = await col.find({ scope, changed_at: { $gt: sinceMs } });
133
+ return `[${rows.map((r) => r.change).join(",")}]`;
134
+ }
135
+ };
136
+ }
137
+ export {
138
+ createSyncHandlers,
139
+ memorySyncStore,
140
+ taladbSyncStore
141
+ };
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@taladb/next",
3
+ "version": "0.9.0",
4
+ "description": "First-party Next.js integration for TalaDB — sync route handlers for the server, a SyncProvider for the client",
5
+ "sideEffects": false,
6
+ "exports": {
7
+ "./server": {
8
+ "types": "./dist/server.d.ts",
9
+ "import": "./dist/server.mjs",
10
+ "require": "./dist/server.js"
11
+ },
12
+ "./client": {
13
+ "types": "./dist/client.d.ts",
14
+ "import": "./dist/client.mjs",
15
+ "require": "./dist/client.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "keywords": [
22
+ "database",
23
+ "local-first",
24
+ "nextjs",
25
+ "sync",
26
+ "offline",
27
+ "taladb"
28
+ ],
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/thinkgrid-labs/taladb.git",
33
+ "directory": "packages/integrations/next"
34
+ },
35
+ "homepage": "https://taladb.dev/guide/bidirectional-sync",
36
+ "bugs": {
37
+ "url": "https://github.com/thinkgrid-labs/taladb/issues"
38
+ },
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "peerDependencies": {
43
+ "@taladb/react": "^0.9.0",
44
+ "react": ">=18.0.0",
45
+ "taladb": "^0.9.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@taladb/react": {
49
+ "optional": true
50
+ },
51
+ "react": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^22.0.0",
57
+ "@types/react": "^18.0.0",
58
+ "react": "^18.0.0",
59
+ "tsup": "^8.0.0",
60
+ "typescript": "^5.9.3",
61
+ "vitest": "^3.2.0",
62
+ "taladb": "0.9.0",
63
+ "@taladb/react": "0.9.0",
64
+ "@taladb/node": "0.9.0"
65
+ },
66
+ "scripts": {
67
+ "build": "tsup",
68
+ "typecheck": "tsc --noEmit",
69
+ "test": "vitest run",
70
+ "test:watch": "vitest"
71
+ }
72
+ }