@softwareseva/sync 0.1.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) 2026 Dinesh Kashikar and contributors
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.
package/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # @softwareseva/sync
2
+
3
+ Server half of offline-first sync. Pair with `kashi_sync` in Flutter.
4
+
5
+ - `POST /push { ops: [{ opId, type, payload }] }`: each op runs its handler once. The result is stored by `(user, opId)`, so a retried batch replays results instead of applying twice. Failures report `retryable` so clients know whether to back off or give up.
6
+ - `GET /pull?since=<seq>`: returns `{ changes: { <entity>: { upserts, deletes } }, next, hasMore, reset }` from the `sync_changes` log for the user's scopes. The cursor is a monotonically increasing sequence, so resuming is exact.
7
+ - `changeStatement()` / `recordChanges()`: log changes from any route (web edits then reach mobile devices).
8
+
9
+ ```ts
10
+ app.route("/v1/sync", syncRouter({
11
+ auth: requireAuth(authConfig),
12
+ user: (c) => c.get("user"),
13
+ scopes: (user) => [`user:${user.id}`],
14
+ handlers: { "note.upsert": { schema: noteSchema, apply: (ctx, p) => { ctx.batch.push(upsertNote(ctx.db, p)); ctx.changed({ scope: `user:${ctx.user.id}`, entity: "notes", id: p.id }); return { id: p.id }; } } },
15
+ entities: { notes: { load: (db, ids, user) => loadNotes(db, ids, user.id) } },
16
+ }));
17
+ ```
18
+
19
+ Ships `migrations/sync_0001_ops_changes.sql`. See the `sync-endpoints` skill.
@@ -0,0 +1,14 @@
1
+ export type ChangeOp = "upsert" | "delete";
2
+ export type Change = {
3
+ scope: string;
4
+ entity: string;
5
+ id: string;
6
+ op?: ChangeOp;
7
+ };
8
+ /** A prepared statement that appends one change; put it in the same `db.batch()` as the write it describes. */
9
+ export declare function changeStatement(db: D1Database, change: Change): D1PreparedStatement;
10
+ /** Append changes on their own (prefer `changeStatement` inside a batch so the write and the log commit together). */
11
+ export declare function recordChanges(db: D1Database, changes: Change[]): Promise<void>;
12
+ /** Delete log entries older than `days`. Clients whose cursor is older must resync from 0 (the pull answers `reset: true`). */
13
+ export declare function pruneChanges(db: D1Database, days?: number): Promise<void>;
14
+ //# sourceMappingURL=changes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"changes.d.ts","sourceRoot":"","sources":["../src/changes.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAC3C,MAAM,MAAM,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,QAAQ,CAAA;CAAE,CAAC;AAElF,+GAA+G;AAC/G,wBAAgB,eAAe,CAAC,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,mBAAmB,CAEnF;AAED,sHAAsH;AACtH,wBAAsB,aAAa,CAAC,EAAE,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAEpF;AAED,+HAA+H;AAC/H,wBAAsB,YAAY,CAAC,EAAE,EAAE,UAAU,EAAE,IAAI,SAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAM3E"}
@@ -0,0 +1,20 @@
1
+ /** Record changes to the sync log. Call from sync handlers and from ordinary online routes alike. */
2
+ import { nowIso } from "@softwareseva/core/server";
3
+ /** A prepared statement that appends one change; put it in the same `db.batch()` as the write it describes. */
4
+ export function changeStatement(db, change) {
5
+ return db.prepare("INSERT INTO sync_changes(scope, entity, entity_id, op, changed_at) VALUES (?, ?, ?, ?, ?)").bind(change.scope, change.entity, change.id, change.op ?? "upsert", nowIso());
6
+ }
7
+ /** Append changes on their own (prefer `changeStatement` inside a batch so the write and the log commit together). */
8
+ export async function recordChanges(db, changes) {
9
+ if (changes.length)
10
+ await db.batch(changes.map((ch) => changeStatement(db, ch)));
11
+ }
12
+ /** Delete log entries older than `days`. Clients whose cursor is older must resync from 0 (the pull answers `reset: true`). */
13
+ export async function pruneChanges(db, days = 90) {
14
+ const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
15
+ await db.batch([
16
+ db.prepare("DELETE FROM sync_changes WHERE changed_at < ?").bind(cutoff),
17
+ db.prepare("DELETE FROM sync_ops WHERE created_at < ?").bind(cutoff),
18
+ ]);
19
+ }
20
+ //# sourceMappingURL=changes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"changes.js","sourceRoot":"","sources":["../src/changes.ts"],"names":[],"mappings":"AAAA,qGAAqG;AACrG,OAAO,EAAE,MAAM,EAAE,MAAM,2BAA2B,CAAC;AAKnD,+GAA+G;AAC/G,MAAM,UAAU,eAAe,CAAC,EAAc,EAAE,MAAc;IAC5D,OAAO,EAAE,CAAC,OAAO,CAAC,2FAA2F,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,EAAE,IAAI,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;AAC/L,CAAC;AAED,sHAAsH;AACtH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,EAAc,EAAE,OAAiB;IACnE,IAAI,OAAO,CAAC,MAAM;QAAE,MAAM,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,eAAe,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;AACnF,CAAC;AAED,+HAA+H;AAC/H,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,EAAc,EAAE,IAAI,GAAG,EAAE;IAC1D,MAAM,MAAM,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,GAAG,UAAU,CAAC,CAAC,WAAW,EAAE,CAAC;IACtE,MAAM,EAAE,CAAC,KAAK,CAAC;QACb,EAAE,CAAC,OAAO,CAAC,+CAA+C,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC;QACxE,EAAE,CAAC,OAAO,CAAC,2CAA2C,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC;KACrE,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,4 @@
1
+ /** @softwareseva/sync — offline-first push/pull for Hono on Workers + D1. */
2
+ export { syncRouter, type SyncConfig, type SyncHandler, type SyncEntity, type HandlerContext, type SyncUser, type PushResult } from "./router";
3
+ export { changeStatement, recordChanges, pruneChanges, type Change, type ChangeOp } from "./changes";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAE,KAAK,WAAW,EAAE,KAAK,UAAU,EAAE,KAAK,cAAc,EAAE,KAAK,QAAQ,EAAE,KAAK,UAAU,EAAE,MAAM,UAAU,CAAC;AAC/I,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,YAAY,EAAE,KAAK,MAAM,EAAE,KAAK,QAAQ,EAAE,MAAM,WAAW,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ /** @softwareseva/sync — offline-first push/pull for Hono on Workers + D1. */
2
+ export { syncRouter } from "./router";
3
+ export { changeStatement, recordChanges, pruneChanges } from "./changes";
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,OAAO,EAAE,UAAU,EAA2G,MAAM,UAAU,CAAC;AAC/I,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,YAAY,EAA8B,MAAM,WAAW,CAAC"}
@@ -0,0 +1,53 @@
1
+ /** syncRouter(config): POST /push applies client operations exactly once; GET /pull streams changes after a cursor. */
2
+ import { Hono, type Context, type MiddlewareHandler } from "hono";
3
+ import { type ZodType } from "zod";
4
+ import { type Change } from "./changes";
5
+ export type SyncUser = {
6
+ id: string;
7
+ };
8
+ export type HandlerContext<U extends SyncUser> = {
9
+ db: D1Database;
10
+ user: U;
11
+ c: Context;
12
+ /** Statements to commit atomically with the change entries. */
13
+ batch: D1PreparedStatement[];
14
+ /** Record a change; it is committed with `batch`. */
15
+ changed: (change: Change) => void;
16
+ };
17
+ export type SyncHandler<U extends SyncUser, P> = {
18
+ schema: ZodType<P>;
19
+ /** Push statements onto ctx.batch and call ctx.changed; return what the client should see. Throw ApiError to reject. */
20
+ apply: (ctx: HandlerContext<U>, payload: P) => Promise<unknown> | unknown;
21
+ };
22
+ export type SyncEntity<U extends SyncUser> = {
23
+ /** Load current rows by id for this user. Rows not returned (missing, soft-deleted, out of scope) are sent as deletes. */
24
+ load: (db: D1Database, ids: string[], user: U) => Promise<Array<{
25
+ id: string;
26
+ } & Record<string, unknown>>>;
27
+ };
28
+ export type SyncConfig<U extends SyncUser> = {
29
+ /** Middleware that authenticates (e.g. requireAuth from @softwareseva/auth). */
30
+ auth: MiddlewareHandler;
31
+ /** Read the user set by `auth`. */
32
+ user: (c: Context) => U;
33
+ /** Scopes this user may pull, e.g. [`user:${id}`, `org:${orgId}`]. */
34
+ scopes: (user: U, c: Context) => string[] | Promise<string[]>;
35
+ handlers: Record<string, SyncHandler<U, any>>;
36
+ entities: Record<string, SyncEntity<U>>;
37
+ /** Max ops per push (default 100) and changes per pull page (default 500). */
38
+ maxOps?: number;
39
+ pageSize?: number;
40
+ db?: (c: Context) => D1Database;
41
+ };
42
+ export type PushResult = {
43
+ opId: string;
44
+ status: "applied" | "replayed" | "failed";
45
+ result?: unknown;
46
+ error?: {
47
+ code: string;
48
+ message: string;
49
+ retryable: boolean;
50
+ };
51
+ };
52
+ export declare function syncRouter<U extends SyncUser>(config: SyncConfig<U>): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
53
+ //# sourceMappingURL=router.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA,uHAAuH;AACvH,OAAO,EAAE,IAAI,EAAE,KAAK,OAAO,EAAE,KAAK,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAClE,OAAO,EAAK,KAAK,OAAO,EAAE,MAAM,KAAK,CAAC;AAEtC,OAAO,EAAmB,KAAK,MAAM,EAAE,MAAM,WAAW,CAAC;AAEzD,MAAM,MAAM,QAAQ,GAAG;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtC,MAAM,MAAM,cAAc,CAAC,CAAC,SAAS,QAAQ,IAAI;IAC/C,EAAE,EAAE,UAAU,CAAC;IACf,IAAI,EAAE,CAAC,CAAC;IACR,CAAC,EAAE,OAAO,CAAC;IACX,+DAA+D;IAC/D,KAAK,EAAE,mBAAmB,EAAE,CAAC;IAC7B,qDAAqD;IACrD,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC,CAAC;AAEF,MAAM,MAAM,WAAW,CAAC,CAAC,SAAS,QAAQ,EAAE,CAAC,IAAI;IAC/C,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IACnB,wHAAwH;IACxH,KAAK,EAAE,CAAC,GAAG,EAAE,cAAc,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC;CAC3E,CAAC;AAEF,MAAM,MAAM,UAAU,CAAC,CAAC,SAAS,QAAQ,IAAI;IAC3C,0HAA0H;IAC1H,IAAI,EAAE,CAAC,EAAE,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;CAC5G,CAAC;AAEF,MAAM,MAAM,UAAU,CAAC,CAAC,SAAS,QAAQ,IAAI;IAC3C,gFAAgF;IAChF,IAAI,EAAE,iBAAiB,CAAC;IACxB,mCAAmC;IACnC,IAAI,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,CAAC,CAAC;IACxB,sEAAsE;IACtE,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC,EAAE,OAAO,KAAK,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC9D,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;IAC9C,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;IACxC,8EAA8E;IAC9E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,UAAU,CAAC;CACjC,CAAC;AAMF,MAAM,MAAM,UAAU,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,SAAS,GAAG,UAAU,GAAG,QAAQ,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,OAAO,CAAA;KAAE,CAAA;CAAE,CAAC;AAEtK,wBAAgB,UAAU,CAAC,CAAC,SAAS,QAAQ,EAAE,MAAM,EAAE,UAAU,CAAC,CAAC,CAAC,8EAkEnE"}
package/dist/router.js ADDED
@@ -0,0 +1,94 @@
1
+ /** syncRouter(config): POST /push applies client operations exactly once; GET /pull streams changes after a cursor. */
2
+ import { Hono } from "hono";
3
+ import { z } from "zod";
4
+ import { ApiError, nowIso, ok } from "@softwareseva/core/server";
5
+ import { changeStatement } from "./changes";
6
+ const pushSchema = (max) => z.object({
7
+ ops: z.array(z.object({ opId: z.string().min(8).max(64), type: z.string().min(1).max(64), payload: z.unknown(), clientTs: z.number().int().optional() })).min(1).max(max),
8
+ });
9
+ export function syncRouter(config) {
10
+ const app = new Hono();
11
+ const dbOf = (c) => (config.db ? config.db(c) : c.env.DB);
12
+ app.use("*", config.auth);
13
+ app.post("/push", async (c) => {
14
+ const db = dbOf(c);
15
+ const user = config.user(c);
16
+ const { ops } = pushSchema(config.maxOps ?? 100).parse(await c.req.json());
17
+ const results = [];
18
+ for (const op of ops) {
19
+ const stored = await db.prepare("SELECT result FROM sync_ops WHERE user_id = ? AND op_id = ?").bind(user.id, op.opId).first();
20
+ if (stored) {
21
+ results.push({ opId: op.opId, status: "replayed", result: stored.result ? JSON.parse(stored.result) : null });
22
+ continue;
23
+ }
24
+ const handler = config.handlers[op.type];
25
+ if (!handler) {
26
+ results.push({ opId: op.opId, status: "failed", error: { code: "UNKNOWN_OP", message: `Unknown operation ${op.type}.`, retryable: false } });
27
+ continue;
28
+ }
29
+ try {
30
+ const payload = handler.schema.parse(op.payload);
31
+ const ctx = { db, user, c, batch: [], changed: (ch) => ctx.batch.push(changeStatement(db, ch)) };
32
+ const result = (await handler.apply(ctx, payload)) ?? null;
33
+ ctx.batch.push(db.prepare("INSERT INTO sync_ops(op_id, user_id, type, result, created_at) VALUES (?, ?, ?, ?, ?)").bind(op.opId, user.id, op.type, JSON.stringify(result), nowIso()));
34
+ await db.batch(ctx.batch);
35
+ results.push({ opId: op.opId, status: "applied", result });
36
+ }
37
+ catch (error) {
38
+ results.push({ opId: op.opId, status: "failed", error: toOpError(error) });
39
+ }
40
+ }
41
+ return ok(c, { results });
42
+ });
43
+ app.get("/pull", async (c) => {
44
+ const db = dbOf(c);
45
+ const user = config.user(c);
46
+ const since = Math.max(0, Number(c.req.query("since") ?? 0) || 0);
47
+ const limit = Math.min(Math.max(Number(c.req.query("limit") ?? config.pageSize ?? 500) || 500, 1), 1000);
48
+ const scopes = await config.scopes(user, c);
49
+ if (!scopes.length)
50
+ return ok(c, { changes: {}, next: since, hasMore: false, reset: false });
51
+ // A cursor older than the oldest retained change means entries were pruned: tell the client to resync.
52
+ const oldest = await db.prepare("SELECT min(seq) AS seq FROM sync_changes").first();
53
+ const reset = since > 0 && oldest?.seq != null && since < oldest.seq - 1;
54
+ const from = reset ? 0 : since;
55
+ const marks = scopes.map(() => "?").join(",");
56
+ const rows = (await db.prepare(`SELECT seq, entity, entity_id, op FROM sync_changes WHERE scope IN (${marks}) AND seq > ? ORDER BY seq LIMIT ?`).bind(...scopes, from, limit + 1).all()).results;
57
+ const hasMore = rows.length > limit;
58
+ const page = rows.slice(0, limit);
59
+ const next = page.length ? page[page.length - 1].seq : from;
60
+ // Latest op per (entity, id) inside the page wins.
61
+ const latest = new Map();
62
+ for (const r of page) {
63
+ if (!config.entities[r.entity])
64
+ continue;
65
+ (latest.get(r.entity) ?? latest.set(r.entity, new Map()).get(r.entity)).set(r.entity_id, r.op);
66
+ }
67
+ const changes = {};
68
+ for (const [entity, ops] of latest) {
69
+ const upsertIds = [...ops].filter(([, op]) => op === "upsert").map(([id]) => id);
70
+ const deletes = [...ops].filter(([, op]) => op === "delete").map(([id]) => id);
71
+ const loaded = upsertIds.length ? await loadChunked(config.entities[entity], db, upsertIds, user) : [];
72
+ const found = new Set(loaded.map((r) => r.id));
73
+ changes[entity] = { upserts: loaded, deletes: [...deletes, ...upsertIds.filter((id) => !found.has(id))] };
74
+ }
75
+ return ok(c, { changes, next, hasMore, reset });
76
+ });
77
+ return app;
78
+ }
79
+ /** D1 allows 100 bound parameters per statement; load in chunks. */
80
+ async function loadChunked(entity, db, ids, user) {
81
+ const out = [];
82
+ for (let i = 0; i < ids.length; i += 90)
83
+ out.push(...(await entity.load(db, ids.slice(i, i + 90), user)));
84
+ return out;
85
+ }
86
+ function toOpError(error) {
87
+ if (error instanceof ApiError)
88
+ return { code: error.code, message: error.message, retryable: error.status === 429 || error.status >= 500 };
89
+ if (error instanceof z.ZodError)
90
+ return { code: "VALIDATION_ERROR", message: error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), retryable: false };
91
+ console.error(JSON.stringify({ level: "error", where: "sync.push", message: error instanceof Error ? error.message : String(error) }));
92
+ return { code: "INTERNAL_ERROR", message: "The server could not apply this change.", retryable: true };
93
+ }
94
+ //# sourceMappingURL=router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,21 @@
1
+ -- @softwareseva/sync: applied client operations (for idempotent replay) and the change log clients pull from.
2
+ CREATE TABLE IF NOT EXISTS sync_ops (
3
+ op_id TEXT NOT NULL,
4
+ user_id TEXT NOT NULL,
5
+ type TEXT NOT NULL,
6
+ result TEXT,
7
+ created_at TEXT NOT NULL,
8
+ PRIMARY KEY (user_id, op_id)
9
+ );
10
+ CREATE INDEX IF NOT EXISTS sync_ops_created ON sync_ops(created_at);
11
+
12
+ -- seq is assigned in commit order because D1 serialises writes per database.
13
+ CREATE TABLE IF NOT EXISTS sync_changes (
14
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
15
+ scope TEXT NOT NULL,
16
+ entity TEXT NOT NULL,
17
+ entity_id TEXT NOT NULL,
18
+ op TEXT NOT NULL CHECK (op IN ('upsert', 'delete')),
19
+ changed_at TEXT NOT NULL
20
+ );
21
+ CREATE INDEX IF NOT EXISTS sync_changes_scope_seq ON sync_changes(scope, seq);
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@softwareseva/sync",
3
+ "version": "0.1.0",
4
+ "description": "Offline-first sync for Hono on Cloudflare Workers + D1: idempotent batched push of client operations with replay, and a change log with a monotonic cursor for pulling upserts and deletes per scope. Pairs with the kashi_sync Flutter package.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/softwareseva/kashi",
9
+ "directory": "packages/sync"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "files": [
14
+ "dist",
15
+ "migrations",
16
+ "secrets.json",
17
+ "README.md"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "dependencies": {
27
+ "@softwareseva/core": "^0.1.0"
28
+ },
29
+ "peerDependencies": {
30
+ "hono": "^4.13.0",
31
+ "zod": "^4.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "@cloudflare/workers-types": "^5.0.0",
35
+ "hono": "^4.13.9",
36
+ "typescript": "^5.9.0",
37
+ "vitest": "^4.1.0",
38
+ "zod": "^4.6.0"
39
+ },
40
+ "scripts": {
41
+ "build": "tsc -p tsconfig.json",
42
+ "typecheck": "tsc -p tsconfig.json --noEmit",
43
+ "test": "vitest run"
44
+ }
45
+ }
package/secrets.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/softwareseva/kashi/main/packages/cli/secrets.schema.json",
3
+ "package": "@softwareseva/sync",
4
+ "secrets": []
5
+ }