@gasboost/replica 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 gasboost
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,464 @@
1
+ # @gasboost/replica
2
+
3
+ Dexie と Zod を利用した、ブラウザ向けの型安全な IndexedDB レプリカです。
4
+
5
+ `@gasboost/replica` は、Zod Schema で定義された Record を IndexedDB 上に保存し、ローカルレプリカとして扱うためのパッケージです。
6
+
7
+ Query 処理は `@gasboost/query` と連携して行うため、Filter、Sort、Pagination、JOIN、Nested JOIN のロジックを Replica 側で重複実装しません。
8
+
9
+ ```text
10
+ Remote Data
11
+
12
+ Full Sync
13
+
14
+ @gasboost/replica
15
+
16
+ IndexedDB / Dexie
17
+
18
+ Loader
19
+
20
+ @gasboost/query
21
+ ```
22
+
23
+ ---
24
+
25
+ ## Features
26
+
27
+ - Dexie を利用した IndexedDB ストレージ
28
+ - Zod Schema からの型推論
29
+ - 型安全な Table 名
30
+ - 型安全な Primary Key
31
+ - `put`
32
+ - `bulkPut`
33
+ - `delete`
34
+ - `toArray`
35
+ - Full Sync
36
+ - Dexie Transaction による同期
37
+ - `@gasboost/query` との統合
38
+ - JOIN
39
+ - Nested JOIN
40
+ - Google Apps Script ランタイム非依存
41
+ - SheetORM ランタイム非依存
42
+
43
+ ---
44
+
45
+ # Installation
46
+
47
+ ```bash
48
+ pnpm add @gasboost/replica @gasboost/query dexie zod
49
+ ```
50
+
51
+ npm の場合:
52
+
53
+ ```bash
54
+ npm install @gasboost/replica @gasboost/query dexie zod
55
+ ```
56
+
57
+ ---
58
+
59
+ # Table Definition
60
+
61
+ Replica では以下を使って Table を定義します。
62
+
63
+ - Table 名
64
+ - Zod Schema
65
+ - Primary Key
66
+
67
+ ```ts
68
+ import { z } from "zod";
69
+
70
+ const UserSchema = z.object({
71
+ id: z.string(),
72
+ name: z.string(),
73
+ active: z.boolean(),
74
+ });
75
+
76
+ const ReservationSchema = z.object({
77
+ id: z.string(),
78
+ userId: z.string(),
79
+ });
80
+
81
+ const tables = [
82
+ {
83
+ name: "users",
84
+ schema: UserSchema,
85
+ primaryKey: "id",
86
+ },
87
+ {
88
+ name: "reservations",
89
+ schema: ReservationSchema,
90
+ primaryKey: "id",
91
+ },
92
+ ] as const;
93
+ ```
94
+
95
+ `primaryKey` には、対応する Zod Schema に存在する Column のみ指定できます。
96
+
97
+ ---
98
+
99
+ # Replica の作成
100
+
101
+ ```ts
102
+ import { createReplica } from "@gasboost/replica";
103
+
104
+ const replica = createReplica({
105
+ name: "example-app",
106
+ tables,
107
+ });
108
+ ```
109
+
110
+ `name` は IndexedDB の Database 名として利用されます。
111
+
112
+ 各 Table は、定義された `primaryKey` を使って Dexie に登録されます。
113
+
114
+ 例えば、
115
+
116
+ ```ts
117
+ {
118
+ name: "users",
119
+ primaryKey: "id",
120
+ }
121
+ ```
122
+
123
+ は概念的に次の Dexie Schema として登録されます。
124
+
125
+ ```text
126
+ users: "id"
127
+ ```
128
+
129
+ ---
130
+
131
+ # Table Access
132
+
133
+ `table()` で Replica 内の Table を参照します。
134
+
135
+ ```ts
136
+ const users = replica.table("users");
137
+ ```
138
+
139
+ Table 名は Table Definition から型推論されます。
140
+
141
+ ```ts
142
+ replica.table("users"); // OK
143
+ replica.table("reservations"); // OK
144
+ ```
145
+
146
+ 定義されていない Table 名は TypeScript 上で拒否されます。
147
+
148
+ ---
149
+
150
+ # Put
151
+
152
+ Record を追加または更新します。
153
+
154
+ ```ts
155
+ await replica.table("users").put({
156
+ id: "u1",
157
+ name: "Alice",
158
+ active: true,
159
+ });
160
+ ```
161
+
162
+ Record 型は、選択した Table の Zod Schema から推論されます。
163
+
164
+ ---
165
+
166
+ # Bulk Put
167
+
168
+ 複数 Record をまとめて追加または更新します。
169
+
170
+ ```ts
171
+ await replica.table("users").bulkPut([
172
+ {
173
+ id: "u1",
174
+ name: "Alice",
175
+ active: true,
176
+ },
177
+ {
178
+ id: "u2",
179
+ name: "Bob",
180
+ active: false,
181
+ },
182
+ ]);
183
+ ```
184
+
185
+ ---
186
+
187
+ # Delete
188
+
189
+ Primary Key を指定して Record を削除します。
190
+
191
+ ```ts
192
+ await replica.table("users").delete("u1");
193
+ ```
194
+
195
+ Primary Key の型は、Table Definition の `primaryKey` Column から推論されます。
196
+
197
+ ---
198
+
199
+ # To Array
200
+
201
+ Table 内の Record をすべて取得します。
202
+
203
+ ```ts
204
+ const users = await replica.table("users").toArray();
205
+ ```
206
+
207
+ 戻り値の型は、対象 Table の Zod Schema から推論されます。
208
+
209
+ ---
210
+
211
+ # Full Sync
212
+
213
+ リモート側から取得した完全な Table データを、ローカル Replica へ同期できます。
214
+
215
+ ```ts
216
+ await replica.sync("users", remoteUsers);
217
+ ```
218
+
219
+ 同期時には、
220
+
221
+ - remote に存在する Record を local に反映
222
+ - remote に存在しない Record を local から削除
223
+ - 同期処理全体を Dexie Transaction 内で実行
224
+
225
+ します。
226
+
227
+ 例えば、local に以下の Record があるとします。
228
+
229
+ ```ts
230
+ await replica.table("users").bulkPut([
231
+ {
232
+ id: "u1",
233
+ name: "Old Alice",
234
+ active: true,
235
+ },
236
+ {
237
+ id: "u2",
238
+ name: "Deleted User",
239
+ active: false,
240
+ },
241
+ ]);
242
+ ```
243
+
244
+ remote の完全データが以下だった場合、
245
+
246
+ ```ts
247
+ await replica.sync("users", [
248
+ {
249
+ id: "u1",
250
+ name: "Alice",
251
+ active: true,
252
+ },
253
+ ]);
254
+ ```
255
+
256
+ 同期後の local は次の状態になります。
257
+
258
+ ```ts
259
+ [
260
+ {
261
+ id: "u1",
262
+ name: "Alice",
263
+ active: true,
264
+ },
265
+ ];
266
+ ```
267
+
268
+ remote に存在しない `u2` は local から削除されます。
269
+
270
+ 空配列を同期すると Table は空になります。
271
+
272
+ ```ts
273
+ await replica.sync("users", []);
274
+ ```
275
+
276
+ ---
277
+
278
+ # Query Integration
279
+
280
+ `@gasboost/replica` は `@gasboost/query` と直接連携できます。
281
+
282
+ ```ts
283
+ import { Query } from "@gasboost/query";
284
+
285
+ const query = new Query<typeof tables, "users">({
286
+ tableName: "users",
287
+ })
288
+ .and("active", "=", [true])
289
+ .orderBy("name", "asc");
290
+ ```
291
+
292
+ Replica に対して Query を実行します。
293
+
294
+ ```ts
295
+ const users = await replica.find(query);
296
+ ```
297
+
298
+ Replica 自身は Filter や Sort のロジックを持ちません。
299
+
300
+ 内部では IndexedDB から Record を取得し、`Query.resolve()` に渡します。
301
+
302
+ ```text
303
+ replica.find(query)
304
+
305
+ query.resolve(loader)
306
+
307
+ replica.table(name).toArray()
308
+ ```
309
+
310
+ これにより、異なる Storage Adapter 間で Query semantics を共有できます。
311
+
312
+ ---
313
+
314
+ # JOIN
315
+
316
+ JOIN の評価は `@gasboost/query` が担当します。
317
+
318
+ ```ts
319
+ const query = new Query<typeof tables, "users">({
320
+ tableName: "users",
321
+ }).join("id", "reservations", "userId");
322
+ ```
323
+
324
+ ```ts
325
+ const users = await replica.find(query);
326
+ ```
327
+
328
+ 結果:
329
+
330
+ ```ts
331
+ [
332
+ {
333
+ id: "u1",
334
+ name: "Alice",
335
+ active: true,
336
+ reservations: [
337
+ {
338
+ id: "r1",
339
+ userId: "u1",
340
+ },
341
+ ],
342
+ },
343
+ ];
344
+ ```
345
+
346
+ Replica 側に JOIN Engine は持ちません。
347
+
348
+ Replica は `@gasboost/query` に Record を提供するだけです。
349
+
350
+ ---
351
+
352
+ # Nested JOIN
353
+
354
+ Nested JOIN も `@gasboost/query` によって解決されます。
355
+
356
+ ```ts
357
+ const reservations = new Query<typeof tables, "reservations">({
358
+ tableName: "reservations",
359
+ }).join("staffId", "staffs", "id");
360
+
361
+ const users = new Query<typeof tables, "users">({
362
+ tableName: "users",
363
+ }).join("id", "reservations", "userId", reservations);
364
+ ```
365
+
366
+ ```ts
367
+ const result = await replica.find(users);
368
+ ```
369
+
370
+ Nested JOIN は `Query.resolve()` によって bottom-up に解決されます。
371
+
372
+ ```text
373
+ staffs
374
+
375
+ reservations
376
+
377
+ users
378
+ ```
379
+
380
+ ---
381
+
382
+ # Architecture
383
+
384
+ `@gasboost/replica` は Storage の責務だけを持ちます。
385
+
386
+ ```text
387
+ @gasboost/replica
388
+ - IndexedDB 初期化
389
+ - Dexie Table
390
+ - put
391
+ - bulkPut
392
+ - delete
393
+ - toArray
394
+ - Full Sync
395
+ - Query Loader
396
+
397
+ @gasboost/query
398
+ - Filter
399
+ - Sort
400
+ - Offset
401
+ - Limit
402
+ - JOIN
403
+ - Nested JOIN
404
+ - Query Resolution
405
+ ```
406
+
407
+ この責務分離により、ローカル Storage とリモート Storage の間で Query 処理を重複実装せずに済みます。
408
+
409
+ ---
410
+
411
+ # Shared Schemas
412
+
413
+ Server と Browser で同じ Table Definition を共有できます。
414
+
415
+ ```ts
416
+ export const tables = [
417
+ {
418
+ name: "users",
419
+ schema: UserSchema,
420
+ primaryKey: "id",
421
+ },
422
+ ] as const;
423
+ ```
424
+
425
+ ```text
426
+ Server / GAS
427
+
428
+ shared tables
429
+
430
+ Browser / Replica
431
+ ```
432
+
433
+ Shared Schema を置く Module には、以下のような Runtime 固有依存を含めないことを推奨します。
434
+
435
+ - `SpreadsheetApp`
436
+ - `CacheService`
437
+ - GAS Handler
438
+ - Browser 固有の Application Code
439
+
440
+ これにより、Server Runtime が Frontend Bundle に混入することを防ぎます。
441
+
442
+ ---
443
+
444
+ # 対象外
445
+
446
+ `@gasboost/replica` core では以下を扱いません。
447
+
448
+ - Firebase Realtime Database 同期
449
+ - 端末間リアルタイム同期
450
+ - Firebase Authentication
451
+ - Row Level Security
452
+ - Offline Mutation Queue
453
+ - Conflict Resolution
454
+ - Incremental Sync
455
+ - Secondary Index を利用した Query 最適化
456
+ - React Hooks
457
+
458
+ これらは Replica core の上に別レイヤーとして追加できます。
459
+
460
+ ---
461
+
462
+ # License
463
+
464
+ MIT
@@ -0,0 +1,16 @@
1
+ import type { Query } from "@gasboost/query";
2
+ import { ReplicaTable } from "./ReplicaTable";
3
+ import { type ReplicaRecord, type ReplicaTableByName, type ReplicaTableDefinition } from "./ReplicaTableDefinition";
4
+ type PrimaryKey<T extends readonly ReplicaTableDefinition[], N extends T[number]["name"]> = ReplicaRecord<T, N>[ReplicaTableByName<T, N>["primaryKey"] & keyof ReplicaRecord<T, N>];
5
+ export declare class Replica<T extends readonly ReplicaTableDefinition[]> {
6
+ private readonly db;
7
+ private readonly tables;
8
+ constructor({ name, tables }: {
9
+ name: string;
10
+ tables: T;
11
+ });
12
+ table<N extends T[number]["name"]>(name: N): ReplicaTable<ReplicaRecord<T, N>, PrimaryKey<T, N>>;
13
+ sync<N extends T[number]["name"]>(name: N, records: ReplicaRecord<T, N>[]): Promise<void>;
14
+ find<N extends T[number]["name"]>(query: Query<T, N>): Promise<Record<string, unknown>[]>;
15
+ }
16
+ export {};
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Replica = void 0;
7
+ const dexie_1 = __importDefault(require("dexie"));
8
+ const ReplicaTable_1 = require("./ReplicaTable");
9
+ class Replica {
10
+ db;
11
+ tables;
12
+ constructor({ name, tables }) {
13
+ this.tables = tables;
14
+ this.db = new dexie_1.default(name);
15
+ const schema = Object.fromEntries(tables.map((table) => [table.name, table.primaryKey]));
16
+ this.db.version(1).stores(schema);
17
+ }
18
+ table(name) {
19
+ return new ReplicaTable_1.ReplicaTable(this.db.table(name));
20
+ }
21
+ async sync(name, records) {
22
+ const table = this.db.table(name);
23
+ await this.db.transaction("rw", table, async () => {
24
+ await table.clear();
25
+ if (records.length > 0) {
26
+ await table.bulkPut(records);
27
+ }
28
+ });
29
+ }
30
+ async find(query) {
31
+ return query.resolve(async (name) => {
32
+ return this.db.table(name).toArray();
33
+ });
34
+ }
35
+ }
36
+ exports.Replica = Replica;
@@ -0,0 +1,9 @@
1
+ import type { Table } from "dexie";
2
+ export declare class ReplicaTable<RecordType, KeyType> {
3
+ private readonly table;
4
+ constructor(table: Table<RecordType, KeyType>);
5
+ put(record: RecordType): Promise<void>;
6
+ bulkPut(records: RecordType[]): Promise<void>;
7
+ delete(key: KeyType): Promise<void>;
8
+ toArray(): Promise<RecordType[]>;
9
+ }
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ReplicaTable = void 0;
4
+ class ReplicaTable {
5
+ table;
6
+ constructor(table) {
7
+ this.table = table;
8
+ }
9
+ async put(record) {
10
+ await this.table.put(record);
11
+ }
12
+ async bulkPut(records) {
13
+ await this.table.bulkPut(records);
14
+ }
15
+ async delete(key) {
16
+ await this.table.delete(key);
17
+ }
18
+ async toArray() {
19
+ return this.table.toArray();
20
+ }
21
+ }
22
+ exports.ReplicaTable = ReplicaTable;
@@ -0,0 +1,9 @@
1
+ import type { TableDefinition } from "@gasboost/query";
2
+ import type { z } from "zod";
3
+ export type ReplicaTableDefinition<N extends string = string, S extends z.ZodObject<any> = z.ZodObject<any>, PK extends Extract<keyof z.infer<S>, string> = Extract<keyof z.infer<S>, string>> = TableDefinition<N, S> & {
4
+ readonly primaryKey: PK;
5
+ };
6
+ export type ReplicaTableByName<T extends readonly ReplicaTableDefinition[], N extends T[number]["name"]> = Extract<T[number], {
7
+ name: N;
8
+ }>;
9
+ export type ReplicaRecord<T extends readonly ReplicaTableDefinition[], N extends T[number]["name"]> = z.infer<ReplicaTableByName<T, N>["schema"]>;
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,6 @@
1
+ import { Replica } from "./Replica";
2
+ import type { ReplicaTableDefinition } from "./ReplicaTableDefinition";
3
+ export declare function createReplica<T extends readonly ReplicaTableDefinition[]>({ name, tables, }: {
4
+ name: string;
5
+ tables: T;
6
+ }): Replica<T>;
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createReplica = createReplica;
4
+ const Replica_1 = require("./Replica");
5
+ function createReplica({ name, tables, }) {
6
+ return new Replica_1.Replica({
7
+ name,
8
+ tables,
9
+ });
10
+ }
@@ -0,0 +1 @@
1
+ export * from "./createReplica";
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./createReplica"), exports);
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@gasboost/replica",
3
+ "version": "0.1.0",
4
+ "description": "Type-safe IndexedDB replica powered by Dexie and Zod",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/gasboost/db.git",
8
+ "directory": "packages/replica"
9
+ },
10
+ "license": "MIT",
11
+ "author": "tiger-oshima",
12
+ "keywords": [
13
+ "indexeddb",
14
+ "dexie",
15
+ "replica",
16
+ "offline",
17
+ "zod",
18
+ "typescript",
19
+ "type-safe"
20
+ ],
21
+ "type": "commonjs",
22
+ "main": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "require": "./dist/index.js",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "README.md"
34
+ ],
35
+ "dependencies": {
36
+ "dexie": "^4.4.6",
37
+ "zod": "^4.5.4",
38
+ "@gasboost/query": "0.1.2"
39
+ },
40
+ "devDependencies": {
41
+ "fake-indexeddb": "^6.2.5"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "scripts": {
47
+ "typecheck": "tsc --noEmit",
48
+ "test": "vitest run",
49
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
50
+ "pack:check": "pnpm pack"
51
+ }
52
+ }