silosdk 0.0.5 → 0.0.7

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/README.md CHANGED
@@ -11,6 +11,7 @@ Silo owns:
11
11
  - defaults-first source shape
12
12
  - settings storage
13
13
  - media file ownership and resolution
14
+ - ID generation through `expo-crypto`
14
15
 
15
16
  TanStack owns:
16
17
 
@@ -21,17 +22,17 @@ TanStack owns:
21
22
  ## Install
22
23
 
23
24
  ```sh
24
- yarn add silosdk @tanstack/react-db @tanstack/react-query expo-sqlite expo-file-system
25
+ yarn add silosdk @tanstack/react-db @tanstack/react-query expo-crypto expo-sqlite expo-file-system
25
26
  ```
26
27
 
27
- ## Sources
28
+ ## Datastores
28
29
 
29
- Sources are SQLite-backed document stores that produce options for TanStack DB
30
- collections.
30
+ Datastores define SQLite-backed document collections and a link collection for
31
+ relationships between them. They produce options for TanStack DB collections.
31
32
 
32
33
  ```ts
33
34
  import { createCollection } from '@tanstack/react-db'
34
- import { createID, source } from 'silosdk/source'
35
+ import { createDatastore, createID } from 'silosdk/source'
35
36
 
36
37
  type Post = {
37
38
  title: string
@@ -40,14 +41,28 @@ type Post = {
40
41
  coverImage: string | null
41
42
  }
42
43
 
43
- export const postsSource = source<Post>('posts', {
44
- title: '',
45
- body: '',
46
- published: false,
47
- coverImage: null,
44
+ type Tag = {
45
+ name: string
46
+ }
47
+
48
+ export const datastore = createDatastore<{
49
+ posts: Post
50
+ tags: Tag
51
+ }>({
52
+ posts: {
53
+ title: '',
54
+ body: '',
55
+ published: false,
56
+ coverImage: null,
57
+ },
58
+ tags: {
59
+ name: '',
60
+ },
48
61
  })
49
62
 
50
- export const posts = createCollection(postsSource.collectionOptions())
63
+ export const posts = createCollection(datastore.collectionOptions('posts'))
64
+ export const tags = createCollection(datastore.collectionOptions('tags'))
65
+ export const links = createCollection(datastore.linkCollectionOptions())
51
66
  ```
52
67
 
53
68
  Query and mutate with TanStack DB directly:
@@ -94,6 +109,49 @@ Source rows are intentionally flat. Field values must be:
94
109
  type FieldValue = string | number | boolean | null
95
110
  ```
96
111
 
112
+ Create links with the link collection utilities:
113
+
114
+ ```ts
115
+ await links.utils.link('posts', postId, 'tags', tagId)
116
+ await links.utils.unlink('posts', postId, 'tags', tagId)
117
+
118
+ const isLinked = links.utils.has('posts', postId, 'tags', tagId)
119
+ ```
120
+
121
+ Link storage is flat and directionless. Collection names are sorted
122
+ lexicographically and IDs are stored in the same order. Query rows expose nested
123
+ paths for each collection pair:
124
+
125
+ ```tsx
126
+ import { eq, useLiveQuery } from '@tanstack/react-db'
127
+
128
+ const { data: taggedPosts = [] } = useLiveQuery((q) =>
129
+ q
130
+ .from({ posts })
131
+ .join({ links }, ({ posts, links }) =>
132
+ eq(posts.id, links.tags.posts.id),
133
+ )
134
+ .join({ tags }, ({ links, tags }) =>
135
+ eq(links.posts.tags.id, tags.id),
136
+ ),
137
+ )
138
+ ```
139
+
140
+ For a `posts` to `tags` link:
141
+
142
+ ```ts
143
+ links.tags.posts.id // post id
144
+ links.posts.tags.id // tag id
145
+ ```
146
+
147
+ Same-collection links are not supported. Define another collection when you need
148
+ a different semantic relationship.
149
+
150
+ ## Sources
151
+
152
+ The lower-level `source()` API remains available when you only need one
153
+ collection definition.
154
+
97
155
  ## Settings
98
156
 
99
157
  Settings are small persisted primitive values backed by `expo-sqlite/kv-store`.
package/dist/media.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  const require_rolldown_runtime = require('./_virtual/rolldown_runtime.cjs');
2
+ let expo_crypto = require("expo-crypto");
2
3
  let expo_sqlite = require("expo-sqlite");
3
- let nanoid = require("nanoid");
4
4
  let __tanstack_react_query = require("@tanstack/react-query");
5
5
  let expo_file_system = require("expo-file-system");
6
6
 
@@ -39,7 +39,7 @@ function mediaQueryKey(ref) {
39
39
  }
40
40
  async function importMedia(input) {
41
41
  assertMediaKind(input.kind);
42
- const id = (0, nanoid.nanoid)();
42
+ const id = (0, expo_crypto.randomUUID)();
43
43
  const ref = createMediaRef(input.kind, id);
44
44
  const source = new expo_file_system.File(input.uri);
45
45
  const name = input.name ?? source.name ?? `${id}${source.extension}`;
package/dist/media.mjs CHANGED
@@ -1,5 +1,5 @@
1
+ import { randomUUID } from "expo-crypto";
1
2
  import { openDatabaseSync } from "expo-sqlite";
2
- import { nanoid } from "nanoid";
3
3
  import { mutationOptions, queryOptions } from "@tanstack/react-query";
4
4
  import { Directory, File, Paths } from "expo-file-system";
5
5
 
@@ -38,7 +38,7 @@ function mediaQueryKey(ref) {
38
38
  }
39
39
  async function importMedia(input) {
40
40
  assertMediaKind(input.kind);
41
- const id = nanoid();
41
+ const id = randomUUID();
42
42
  const ref = createMediaRef(input.kind, id);
43
43
  const source = new File(input.uri);
44
44
  const name = input.name ?? source.name ?? `${id}${source.extension}`;
package/dist/source.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  const require_rolldown_runtime = require('./_virtual/rolldown_runtime.cjs');
2
+ let expo_crypto = require("expo-crypto");
2
3
  let expo_sqlite = require("expo-sqlite");
3
- let nanoid = require("nanoid");
4
4
 
5
5
  //#region src/source/index.ts
6
6
  const sourceNames = /* @__PURE__ */ new Set();
@@ -12,51 +12,207 @@ const reservedDocumentFields = new Set([
12
12
  ]);
13
13
  const deletedDocs = /* @__PURE__ */ new WeakSet();
14
14
  function createID() {
15
- return (0, nanoid.nanoid)();
15
+ return (0, expo_crypto.randomUUID)();
16
+ }
17
+ function createDatastore(defaults) {
18
+ const names = Object.keys(defaults);
19
+ const nameSet = new Set(names);
20
+ for (const name of names) {
21
+ assertSourceName(name);
22
+ assertFlatDefaults(name, defaults[name]);
23
+ }
24
+ if (new Set(names).size !== names.length) throw new Error("Datastore collection names must be unique.");
25
+ return {
26
+ names,
27
+ collectionOptions(name, options = {}) {
28
+ assertDatastoreCollectionName(nameSet, name);
29
+ return createSourceCollectionOptions(name, defaults[name], options);
30
+ },
31
+ linkCollectionOptions(options = {}) {
32
+ return createLinkCollectionOptions(names, nameSet, options);
33
+ }
34
+ };
16
35
  }
17
36
  function source(name, defaults) {
18
37
  assertSourceName(name);
19
38
  assertFlatDefaults(name, defaults);
20
39
  if (sourceNames.has(name)) throw new Error(`Source "${name}" is already registered. Define each source once and import the existing source instead.`);
21
40
  sourceNames.add(name);
22
- const schema = createSourceSchema(name, defaults);
23
41
  return {
24
42
  name,
25
43
  defaults,
26
- schema,
44
+ schema: createSourceSchema(name, defaults),
27
45
  collectionOptions(options = {}) {
28
- return {
29
- id: name,
30
- getKey: (item) => item.id,
31
- schema,
32
- startSync: options.startSync,
33
- sync: { sync({ begin, write, commit, markReady, truncate }) {
34
- const rows = loadRows(name);
35
- begin();
36
- truncate();
37
- for (const row of rows) write({
38
- type: "insert",
39
- value: row
40
- });
41
- commit();
42
- markReady();
43
- } },
44
- onInsert: async ({ transaction }) => {
45
- await insertRows(name, transaction.mutations.map((mutation) => mutation.modified));
46
- },
47
- onUpdate: async ({ transaction }) => {
48
- await updateRows(name, transaction.mutations);
49
- },
50
- onDelete: async ({ transaction }) => {
51
- await deleteRows(name, transaction.mutations);
52
- }
53
- };
46
+ return createSourceCollectionOptions(name, defaults, options);
47
+ }
48
+ };
49
+ }
50
+ function createSourceCollectionOptions(name, defaults, options = {}) {
51
+ return {
52
+ id: name,
53
+ getKey: (item) => item.id,
54
+ schema: createSourceSchema(name, defaults),
55
+ startSync: options.startSync,
56
+ sync: { sync({ begin, write, commit, markReady, truncate }) {
57
+ const rows = loadRows(name);
58
+ begin();
59
+ truncate();
60
+ for (const row of rows) write({
61
+ type: "insert",
62
+ value: row
63
+ });
64
+ commit();
65
+ markReady();
66
+ } },
67
+ onInsert: async ({ transaction }) => {
68
+ await insertRows(name, transaction.mutations.map((mutation) => mutation.modified));
69
+ },
70
+ onUpdate: async ({ transaction }) => {
71
+ await updateRows(name, transaction.mutations);
72
+ },
73
+ onDelete: async ({ transaction }) => {
74
+ await deleteRows(name, transaction.mutations);
75
+ }
76
+ };
77
+ }
78
+ function createLinkCollectionOptions(names, nameSet, options = {}) {
79
+ const schema = createLinkSchema();
80
+ let syncBegin;
81
+ let syncWrite;
82
+ let syncCommit;
83
+ const writeSyncedLink = (type, row) => {
84
+ if (!syncBegin || !syncWrite || !syncCommit) return;
85
+ syncBegin();
86
+ syncWrite({
87
+ type,
88
+ value: row
89
+ });
90
+ syncCommit();
91
+ };
92
+ return {
93
+ id: "silo-links",
94
+ getKey: (item) => item.id,
95
+ schema,
96
+ startSync: options.startSync,
97
+ sync: { sync({ begin, write, commit, markReady, truncate }) {
98
+ syncBegin = begin;
99
+ syncWrite = write;
100
+ syncCommit = commit;
101
+ const rows = loadLinkRows().map((row) => createLinkRow(names, row));
102
+ begin();
103
+ truncate();
104
+ for (const row of rows) write({
105
+ type: "insert",
106
+ value: row
107
+ });
108
+ commit();
109
+ markReady();
110
+ } },
111
+ onInsert: async ({ transaction }) => {
112
+ for (const mutation of transaction.mutations) insertLinkRow(extractStoredLinkRow(mutation.modified));
113
+ },
114
+ onDelete: async ({ transaction }) => {
115
+ for (const mutation of transaction.mutations) deleteLinkRow(String(mutation.key));
116
+ },
117
+ utils: {
118
+ async link(collectionA, idA, collectionB, idB) {
119
+ const row = createStoredLinkRow(nameSet, collectionA, idA, collectionB, idB);
120
+ const exists = hasLinkRow(row.id);
121
+ insertLinkRow(row);
122
+ if (!exists) writeSyncedLink("insert", createLinkRow(names, row));
123
+ },
124
+ async unlink(collectionA, idA, collectionB, idB) {
125
+ const row = createStoredLinkRow(nameSet, collectionA, idA, collectionB, idB);
126
+ const exists = hasLinkRow(row.id);
127
+ deleteLinkRow(row.id);
128
+ if (exists) writeSyncedLink("delete", createLinkRow(names, row));
129
+ },
130
+ has(collectionA, idA, collectionB, idB) {
131
+ return hasLinkRow(createStoredLinkRow(nameSet, collectionA, idA, collectionB, idB).id);
132
+ }
54
133
  }
55
134
  };
56
135
  }
57
136
  function assertSourceName(name) {
58
137
  if (!sourceNamePattern.test(name)) throw new Error(`Source names must start with a letter and contain only letters, numbers, underscores, or hyphens. Received "${name}".`);
59
138
  }
139
+ function assertDatastoreCollectionName(nameSet, name) {
140
+ if (!nameSet.has(name)) throw new Error(`Datastore collection "${name}" is not registered.`);
141
+ }
142
+ function assertLinkCollectionName(nameSet, name) {
143
+ if (!nameSet.has(name)) throw new Error(`Cannot link unknown collection "${name}".`);
144
+ }
145
+ function createStoredLinkRow(nameSet, collectionA, idA, collectionB, idB) {
146
+ assertLinkCollectionName(nameSet, collectionA);
147
+ assertLinkCollectionName(nameSet, collectionB);
148
+ if (collectionA === collectionB) throw new Error(`Links between the same collection are not supported. Received "${collectionA}".`);
149
+ const [firstCollection, firstId, secondCollection, secondId] = collectionA < collectionB ? [
150
+ collectionA,
151
+ idA,
152
+ collectionB,
153
+ idB
154
+ ] : [
155
+ collectionB,
156
+ idB,
157
+ collectionA,
158
+ idA
159
+ ];
160
+ const collections = encodePair(firstCollection, secondCollection);
161
+ const ids = encodePair(firstId, secondId);
162
+ return {
163
+ id: encodePair(collections, ids),
164
+ collections,
165
+ ids,
166
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
167
+ };
168
+ }
169
+ function createLinkRow(names, row) {
170
+ const [firstCollection, secondCollection] = decodePair(row.collections);
171
+ const [firstId, secondId] = decodePair(row.ids);
172
+ const result = {
173
+ id: row.id,
174
+ collections: row.collections,
175
+ ids: row.ids,
176
+ createdAt: row.createdAt
177
+ };
178
+ for (const other of names) {
179
+ const byCurrent = {};
180
+ for (const current of names) {
181
+ let id = null;
182
+ if (other !== current) {
183
+ if (current === firstCollection && other === secondCollection) id = firstId;
184
+ else if (current === secondCollection && other === firstCollection) id = secondId;
185
+ }
186
+ byCurrent[current] = { id };
187
+ }
188
+ result[other] = byCurrent;
189
+ }
190
+ return result;
191
+ }
192
+ function extractStoredLinkRow(row) {
193
+ if (!isRecord(row)) throw new Error("Expected a link row object.");
194
+ if (typeof row.id !== "string" || typeof row.collections !== "string" || typeof row.ids !== "string" || typeof row.createdAt !== "string") throw new Error("Link rows must include string id, collections, ids, and createdAt fields.");
195
+ return {
196
+ id: row.id,
197
+ collections: row.collections,
198
+ ids: row.ids,
199
+ createdAt: row.createdAt
200
+ };
201
+ }
202
+ function encodePair(first, second) {
203
+ return `${encodePart(first)}:${encodePart(second)}`;
204
+ }
205
+ function decodePair(value) {
206
+ const parts = value.split(":");
207
+ if (parts.length !== 2) throw new Error(`Invalid encoded link pair "${value}".`);
208
+ return [decodePart(parts[0]), decodePart(parts[1])];
209
+ }
210
+ function encodePart(value) {
211
+ return encodeURIComponent(value);
212
+ }
213
+ function decodePart(value) {
214
+ return decodeURIComponent(value);
215
+ }
60
216
  function assertFlatDefaults(sourceName, defaults) {
61
217
  for (const [field, defaultValue] of Object.entries(defaults)) {
62
218
  if (reservedDocumentFields.has(field)) throw new Error(`Source "${sourceName}" field "${field}" is reserved by Silo documents.`);
@@ -85,6 +241,20 @@ function createSourceSchema(sourceName, defaults) {
85
241
  }
86
242
  } };
87
243
  }
244
+ function createLinkSchema() {
245
+ return { "~standard": {
246
+ version: 1,
247
+ vendor: "silo",
248
+ validate(value) {
249
+ try {
250
+ extractStoredLinkRow(value);
251
+ return { value };
252
+ } catch (error) {
253
+ return failure(error instanceof Error ? error.message : "Expected a link row.");
254
+ }
255
+ }
256
+ } };
257
+ }
88
258
  function createDoc(id, data) {
89
259
  const doc = {};
90
260
  Object.defineProperty(doc, "id", {
@@ -160,6 +330,19 @@ function getDatabase() {
160
330
  version INTEGER NOT NULL DEFAULT 1,
161
331
  PRIMARY KEY (source, id)
162
332
  );
333
+
334
+ CREATE TABLE IF NOT EXISTS links (
335
+ id TEXT PRIMARY KEY,
336
+ collections TEXT NOT NULL,
337
+ ids TEXT NOT NULL,
338
+ createdAt TEXT NOT NULL
339
+ );
340
+
341
+ CREATE UNIQUE INDEX IF NOT EXISTS links_collections_ids_idx
342
+ ON links(collections, ids);
343
+
344
+ CREATE INDEX IF NOT EXISTS links_collections_idx
345
+ ON links(collections);
163
346
  `);
164
347
  }
165
348
  return database;
@@ -211,11 +394,39 @@ function deleteRows(sourceName, mutations) {
211
394
  if (mutation.original) deletedDocs.add(mutation.original);
212
395
  deletedDocs.add(mutation.modified);
213
396
  db.runSync(`DELETE FROM sources WHERE source = ? AND id = ?`, [sourceName, String(mutation.key)]);
397
+ deleteLinksForSourceRow(sourceName, String(mutation.key));
214
398
  }
215
399
  });
216
400
  return Promise.resolve();
217
401
  }
402
+ function loadLinkRows() {
403
+ return getDatabase().getAllSync(`SELECT id, collections, ids, createdAt FROM links`);
404
+ }
405
+ function insertLinkRow(row) {
406
+ getDatabase().runSync(`INSERT OR IGNORE INTO links (id, collections, ids, createdAt)
407
+ VALUES (?, ?, ?, ?)`, [
408
+ row.id,
409
+ row.collections,
410
+ row.ids,
411
+ row.createdAt
412
+ ]);
413
+ }
414
+ function deleteLinkRow(id) {
415
+ getDatabase().runSync(`DELETE FROM links WHERE id = ?`, [id]);
416
+ }
417
+ function hasLinkRow(id) {
418
+ return !!getDatabase().getFirstSync(`SELECT id FROM links WHERE id = ?`, [id]);
419
+ }
420
+ function deleteLinksForSourceRow(sourceName, id) {
421
+ const rows = loadLinkRows();
422
+ for (const row of rows) {
423
+ const [firstCollection, secondCollection] = decodePair(row.collections);
424
+ const [firstId, secondId] = decodePair(row.ids);
425
+ if (firstCollection === sourceName && firstId === id || secondCollection === sourceName && secondId === id) deleteLinkRow(row.id);
426
+ }
427
+ }
218
428
 
219
429
  //#endregion
430
+ exports.createDatastore = createDatastore;
220
431
  exports.createID = createID;
221
432
  exports.source = source;
package/dist/source.d.cts CHANGED
@@ -22,6 +22,30 @@ type SourceRow<T extends object> = Doc<T>;
22
22
  type SourceCollectionOptions<T extends object> = {
23
23
  startSync?: boolean;
24
24
  };
25
+ type DatastoreDefaults<TCollections extends Record<string, object>> = { [Name in keyof TCollections]: SourceDefaults<TCollections[Name]> };
26
+ type LinkRow<TName extends string = string> = {
27
+ readonly id: string;
28
+ readonly collections: string;
29
+ readonly ids: string;
30
+ readonly createdAt: string;
31
+ } & { readonly [Other in TName]: { readonly [Current in TName]: {
32
+ readonly id: string | null;
33
+ } } };
34
+ type LinkUtils<TName extends string = string> = {
35
+ link(collectionA: TName, idA: string, collectionB: TName, idB: string): Promise<void>;
36
+ unlink(collectionA: TName, idA: string, collectionB: TName, idB: string): Promise<void>;
37
+ has(collectionA: TName, idA: string, collectionB: TName, idB: string): boolean;
38
+ };
39
+ type Datastore<TCollections extends Record<string, object>> = {
40
+ readonly names: ReadonlyArray<keyof TCollections & string>;
41
+ collectionOptions<Name$1 extends keyof TCollections & string>(name: Name$1, options?: SourceCollectionOptions<TCollections[Name$1]>): CollectionConfig<SourceRow<TCollections[Name$1]>, string, StandardSchemaV1<any, SourceRow<TCollections[Name$1]>>> & {
42
+ schema: StandardSchemaV1<any, SourceRow<TCollections[Name$1]>>;
43
+ };
44
+ linkCollectionOptions(options?: SourceCollectionOptions<LinkRow<keyof TCollections & string>>): CollectionConfig<LinkRow<keyof TCollections & string>, string, StandardSchemaV1<any, LinkRow<keyof TCollections & string>>, LinkUtils<keyof TCollections & string>> & {
45
+ schema: StandardSchemaV1<any, LinkRow<keyof TCollections & string>>;
46
+ utils: LinkUtils<keyof TCollections & string>;
47
+ };
48
+ };
25
49
  type Source<T extends object> = {
26
50
  readonly name: string;
27
51
  readonly defaults: SourceDefaults<T>;
@@ -31,6 +55,7 @@ type Source<T extends object> = {
31
55
  };
32
56
  };
33
57
  declare function createID(): string;
58
+ declare function createDatastore<TCollections extends Record<string, object>>(defaults: DatastoreDefaults<TCollections>): Datastore<TCollections>;
34
59
  declare function source<T extends object>(name: string, defaults: SourceDefaults<T>): Source<T>;
35
60
  //#endregion
36
- export { DefaultValue, Doc, DocumentData, FieldValue, Source, SourceCollectionOptions, SourceDefaults, SourceInput, SourceRow, createID, source };
61
+ export { Datastore, DatastoreDefaults, DefaultValue, Doc, DocumentData, FieldValue, LinkRow, LinkUtils, Source, SourceCollectionOptions, SourceDefaults, SourceInput, SourceRow, createDatastore, createID, source };
package/dist/source.d.mts CHANGED
@@ -22,6 +22,30 @@ type SourceRow<T extends object> = Doc<T>;
22
22
  type SourceCollectionOptions<T extends object> = {
23
23
  startSync?: boolean;
24
24
  };
25
+ type DatastoreDefaults<TCollections extends Record<string, object>> = { [Name in keyof TCollections]: SourceDefaults<TCollections[Name]> };
26
+ type LinkRow<TName extends string = string> = {
27
+ readonly id: string;
28
+ readonly collections: string;
29
+ readonly ids: string;
30
+ readonly createdAt: string;
31
+ } & { readonly [Other in TName]: { readonly [Current in TName]: {
32
+ readonly id: string | null;
33
+ } } };
34
+ type LinkUtils<TName extends string = string> = {
35
+ link(collectionA: TName, idA: string, collectionB: TName, idB: string): Promise<void>;
36
+ unlink(collectionA: TName, idA: string, collectionB: TName, idB: string): Promise<void>;
37
+ has(collectionA: TName, idA: string, collectionB: TName, idB: string): boolean;
38
+ };
39
+ type Datastore<TCollections extends Record<string, object>> = {
40
+ readonly names: ReadonlyArray<keyof TCollections & string>;
41
+ collectionOptions<Name$1 extends keyof TCollections & string>(name: Name$1, options?: SourceCollectionOptions<TCollections[Name$1]>): CollectionConfig<SourceRow<TCollections[Name$1]>, string, StandardSchemaV1<any, SourceRow<TCollections[Name$1]>>> & {
42
+ schema: StandardSchemaV1<any, SourceRow<TCollections[Name$1]>>;
43
+ };
44
+ linkCollectionOptions(options?: SourceCollectionOptions<LinkRow<keyof TCollections & string>>): CollectionConfig<LinkRow<keyof TCollections & string>, string, StandardSchemaV1<any, LinkRow<keyof TCollections & string>>, LinkUtils<keyof TCollections & string>> & {
45
+ schema: StandardSchemaV1<any, LinkRow<keyof TCollections & string>>;
46
+ utils: LinkUtils<keyof TCollections & string>;
47
+ };
48
+ };
25
49
  type Source<T extends object> = {
26
50
  readonly name: string;
27
51
  readonly defaults: SourceDefaults<T>;
@@ -31,6 +55,7 @@ type Source<T extends object> = {
31
55
  };
32
56
  };
33
57
  declare function createID(): string;
58
+ declare function createDatastore<TCollections extends Record<string, object>>(defaults: DatastoreDefaults<TCollections>): Datastore<TCollections>;
34
59
  declare function source<T extends object>(name: string, defaults: SourceDefaults<T>): Source<T>;
35
60
  //#endregion
36
- export { DefaultValue, Doc, DocumentData, FieldValue, Source, SourceCollectionOptions, SourceDefaults, SourceInput, SourceRow, createID, source };
61
+ export { Datastore, DatastoreDefaults, DefaultValue, Doc, DocumentData, FieldValue, LinkRow, LinkUtils, Source, SourceCollectionOptions, SourceDefaults, SourceInput, SourceRow, createDatastore, createID, source };
package/dist/source.mjs CHANGED
@@ -1,5 +1,5 @@
1
+ import { randomUUID } from "expo-crypto";
1
2
  import { openDatabaseSync } from "expo-sqlite";
2
- import { nanoid } from "nanoid";
3
3
 
4
4
  //#region src/source/index.ts
5
5
  const sourceNames = /* @__PURE__ */ new Set();
@@ -11,51 +11,207 @@ const reservedDocumentFields = new Set([
11
11
  ]);
12
12
  const deletedDocs = /* @__PURE__ */ new WeakSet();
13
13
  function createID() {
14
- return nanoid();
14
+ return randomUUID();
15
+ }
16
+ function createDatastore(defaults) {
17
+ const names = Object.keys(defaults);
18
+ const nameSet = new Set(names);
19
+ for (const name of names) {
20
+ assertSourceName(name);
21
+ assertFlatDefaults(name, defaults[name]);
22
+ }
23
+ if (new Set(names).size !== names.length) throw new Error("Datastore collection names must be unique.");
24
+ return {
25
+ names,
26
+ collectionOptions(name, options = {}) {
27
+ assertDatastoreCollectionName(nameSet, name);
28
+ return createSourceCollectionOptions(name, defaults[name], options);
29
+ },
30
+ linkCollectionOptions(options = {}) {
31
+ return createLinkCollectionOptions(names, nameSet, options);
32
+ }
33
+ };
15
34
  }
16
35
  function source(name, defaults) {
17
36
  assertSourceName(name);
18
37
  assertFlatDefaults(name, defaults);
19
38
  if (sourceNames.has(name)) throw new Error(`Source "${name}" is already registered. Define each source once and import the existing source instead.`);
20
39
  sourceNames.add(name);
21
- const schema = createSourceSchema(name, defaults);
22
40
  return {
23
41
  name,
24
42
  defaults,
25
- schema,
43
+ schema: createSourceSchema(name, defaults),
26
44
  collectionOptions(options = {}) {
27
- return {
28
- id: name,
29
- getKey: (item) => item.id,
30
- schema,
31
- startSync: options.startSync,
32
- sync: { sync({ begin, write, commit, markReady, truncate }) {
33
- const rows = loadRows(name);
34
- begin();
35
- truncate();
36
- for (const row of rows) write({
37
- type: "insert",
38
- value: row
39
- });
40
- commit();
41
- markReady();
42
- } },
43
- onInsert: async ({ transaction }) => {
44
- await insertRows(name, transaction.mutations.map((mutation) => mutation.modified));
45
- },
46
- onUpdate: async ({ transaction }) => {
47
- await updateRows(name, transaction.mutations);
48
- },
49
- onDelete: async ({ transaction }) => {
50
- await deleteRows(name, transaction.mutations);
51
- }
52
- };
45
+ return createSourceCollectionOptions(name, defaults, options);
46
+ }
47
+ };
48
+ }
49
+ function createSourceCollectionOptions(name, defaults, options = {}) {
50
+ return {
51
+ id: name,
52
+ getKey: (item) => item.id,
53
+ schema: createSourceSchema(name, defaults),
54
+ startSync: options.startSync,
55
+ sync: { sync({ begin, write, commit, markReady, truncate }) {
56
+ const rows = loadRows(name);
57
+ begin();
58
+ truncate();
59
+ for (const row of rows) write({
60
+ type: "insert",
61
+ value: row
62
+ });
63
+ commit();
64
+ markReady();
65
+ } },
66
+ onInsert: async ({ transaction }) => {
67
+ await insertRows(name, transaction.mutations.map((mutation) => mutation.modified));
68
+ },
69
+ onUpdate: async ({ transaction }) => {
70
+ await updateRows(name, transaction.mutations);
71
+ },
72
+ onDelete: async ({ transaction }) => {
73
+ await deleteRows(name, transaction.mutations);
74
+ }
75
+ };
76
+ }
77
+ function createLinkCollectionOptions(names, nameSet, options = {}) {
78
+ const schema = createLinkSchema();
79
+ let syncBegin;
80
+ let syncWrite;
81
+ let syncCommit;
82
+ const writeSyncedLink = (type, row) => {
83
+ if (!syncBegin || !syncWrite || !syncCommit) return;
84
+ syncBegin();
85
+ syncWrite({
86
+ type,
87
+ value: row
88
+ });
89
+ syncCommit();
90
+ };
91
+ return {
92
+ id: "silo-links",
93
+ getKey: (item) => item.id,
94
+ schema,
95
+ startSync: options.startSync,
96
+ sync: { sync({ begin, write, commit, markReady, truncate }) {
97
+ syncBegin = begin;
98
+ syncWrite = write;
99
+ syncCommit = commit;
100
+ const rows = loadLinkRows().map((row) => createLinkRow(names, row));
101
+ begin();
102
+ truncate();
103
+ for (const row of rows) write({
104
+ type: "insert",
105
+ value: row
106
+ });
107
+ commit();
108
+ markReady();
109
+ } },
110
+ onInsert: async ({ transaction }) => {
111
+ for (const mutation of transaction.mutations) insertLinkRow(extractStoredLinkRow(mutation.modified));
112
+ },
113
+ onDelete: async ({ transaction }) => {
114
+ for (const mutation of transaction.mutations) deleteLinkRow(String(mutation.key));
115
+ },
116
+ utils: {
117
+ async link(collectionA, idA, collectionB, idB) {
118
+ const row = createStoredLinkRow(nameSet, collectionA, idA, collectionB, idB);
119
+ const exists = hasLinkRow(row.id);
120
+ insertLinkRow(row);
121
+ if (!exists) writeSyncedLink("insert", createLinkRow(names, row));
122
+ },
123
+ async unlink(collectionA, idA, collectionB, idB) {
124
+ const row = createStoredLinkRow(nameSet, collectionA, idA, collectionB, idB);
125
+ const exists = hasLinkRow(row.id);
126
+ deleteLinkRow(row.id);
127
+ if (exists) writeSyncedLink("delete", createLinkRow(names, row));
128
+ },
129
+ has(collectionA, idA, collectionB, idB) {
130
+ return hasLinkRow(createStoredLinkRow(nameSet, collectionA, idA, collectionB, idB).id);
131
+ }
53
132
  }
54
133
  };
55
134
  }
56
135
  function assertSourceName(name) {
57
136
  if (!sourceNamePattern.test(name)) throw new Error(`Source names must start with a letter and contain only letters, numbers, underscores, or hyphens. Received "${name}".`);
58
137
  }
138
+ function assertDatastoreCollectionName(nameSet, name) {
139
+ if (!nameSet.has(name)) throw new Error(`Datastore collection "${name}" is not registered.`);
140
+ }
141
+ function assertLinkCollectionName(nameSet, name) {
142
+ if (!nameSet.has(name)) throw new Error(`Cannot link unknown collection "${name}".`);
143
+ }
144
+ function createStoredLinkRow(nameSet, collectionA, idA, collectionB, idB) {
145
+ assertLinkCollectionName(nameSet, collectionA);
146
+ assertLinkCollectionName(nameSet, collectionB);
147
+ if (collectionA === collectionB) throw new Error(`Links between the same collection are not supported. Received "${collectionA}".`);
148
+ const [firstCollection, firstId, secondCollection, secondId] = collectionA < collectionB ? [
149
+ collectionA,
150
+ idA,
151
+ collectionB,
152
+ idB
153
+ ] : [
154
+ collectionB,
155
+ idB,
156
+ collectionA,
157
+ idA
158
+ ];
159
+ const collections = encodePair(firstCollection, secondCollection);
160
+ const ids = encodePair(firstId, secondId);
161
+ return {
162
+ id: encodePair(collections, ids),
163
+ collections,
164
+ ids,
165
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
166
+ };
167
+ }
168
+ function createLinkRow(names, row) {
169
+ const [firstCollection, secondCollection] = decodePair(row.collections);
170
+ const [firstId, secondId] = decodePair(row.ids);
171
+ const result = {
172
+ id: row.id,
173
+ collections: row.collections,
174
+ ids: row.ids,
175
+ createdAt: row.createdAt
176
+ };
177
+ for (const other of names) {
178
+ const byCurrent = {};
179
+ for (const current of names) {
180
+ let id = null;
181
+ if (other !== current) {
182
+ if (current === firstCollection && other === secondCollection) id = firstId;
183
+ else if (current === secondCollection && other === firstCollection) id = secondId;
184
+ }
185
+ byCurrent[current] = { id };
186
+ }
187
+ result[other] = byCurrent;
188
+ }
189
+ return result;
190
+ }
191
+ function extractStoredLinkRow(row) {
192
+ if (!isRecord(row)) throw new Error("Expected a link row object.");
193
+ if (typeof row.id !== "string" || typeof row.collections !== "string" || typeof row.ids !== "string" || typeof row.createdAt !== "string") throw new Error("Link rows must include string id, collections, ids, and createdAt fields.");
194
+ return {
195
+ id: row.id,
196
+ collections: row.collections,
197
+ ids: row.ids,
198
+ createdAt: row.createdAt
199
+ };
200
+ }
201
+ function encodePair(first, second) {
202
+ return `${encodePart(first)}:${encodePart(second)}`;
203
+ }
204
+ function decodePair(value) {
205
+ const parts = value.split(":");
206
+ if (parts.length !== 2) throw new Error(`Invalid encoded link pair "${value}".`);
207
+ return [decodePart(parts[0]), decodePart(parts[1])];
208
+ }
209
+ function encodePart(value) {
210
+ return encodeURIComponent(value);
211
+ }
212
+ function decodePart(value) {
213
+ return decodeURIComponent(value);
214
+ }
59
215
  function assertFlatDefaults(sourceName, defaults) {
60
216
  for (const [field, defaultValue] of Object.entries(defaults)) {
61
217
  if (reservedDocumentFields.has(field)) throw new Error(`Source "${sourceName}" field "${field}" is reserved by Silo documents.`);
@@ -84,6 +240,20 @@ function createSourceSchema(sourceName, defaults) {
84
240
  }
85
241
  } };
86
242
  }
243
+ function createLinkSchema() {
244
+ return { "~standard": {
245
+ version: 1,
246
+ vendor: "silo",
247
+ validate(value) {
248
+ try {
249
+ extractStoredLinkRow(value);
250
+ return { value };
251
+ } catch (error) {
252
+ return failure(error instanceof Error ? error.message : "Expected a link row.");
253
+ }
254
+ }
255
+ } };
256
+ }
87
257
  function createDoc(id, data) {
88
258
  const doc = {};
89
259
  Object.defineProperty(doc, "id", {
@@ -159,6 +329,19 @@ function getDatabase() {
159
329
  version INTEGER NOT NULL DEFAULT 1,
160
330
  PRIMARY KEY (source, id)
161
331
  );
332
+
333
+ CREATE TABLE IF NOT EXISTS links (
334
+ id TEXT PRIMARY KEY,
335
+ collections TEXT NOT NULL,
336
+ ids TEXT NOT NULL,
337
+ createdAt TEXT NOT NULL
338
+ );
339
+
340
+ CREATE UNIQUE INDEX IF NOT EXISTS links_collections_ids_idx
341
+ ON links(collections, ids);
342
+
343
+ CREATE INDEX IF NOT EXISTS links_collections_idx
344
+ ON links(collections);
162
345
  `);
163
346
  }
164
347
  return database;
@@ -210,10 +393,37 @@ function deleteRows(sourceName, mutations) {
210
393
  if (mutation.original) deletedDocs.add(mutation.original);
211
394
  deletedDocs.add(mutation.modified);
212
395
  db.runSync(`DELETE FROM sources WHERE source = ? AND id = ?`, [sourceName, String(mutation.key)]);
396
+ deleteLinksForSourceRow(sourceName, String(mutation.key));
213
397
  }
214
398
  });
215
399
  return Promise.resolve();
216
400
  }
401
+ function loadLinkRows() {
402
+ return getDatabase().getAllSync(`SELECT id, collections, ids, createdAt FROM links`);
403
+ }
404
+ function insertLinkRow(row) {
405
+ getDatabase().runSync(`INSERT OR IGNORE INTO links (id, collections, ids, createdAt)
406
+ VALUES (?, ?, ?, ?)`, [
407
+ row.id,
408
+ row.collections,
409
+ row.ids,
410
+ row.createdAt
411
+ ]);
412
+ }
413
+ function deleteLinkRow(id) {
414
+ getDatabase().runSync(`DELETE FROM links WHERE id = ?`, [id]);
415
+ }
416
+ function hasLinkRow(id) {
417
+ return !!getDatabase().getFirstSync(`SELECT id FROM links WHERE id = ?`, [id]);
418
+ }
419
+ function deleteLinksForSourceRow(sourceName, id) {
420
+ const rows = loadLinkRows();
421
+ for (const row of rows) {
422
+ const [firstCollection, secondCollection] = decodePair(row.collections);
423
+ const [firstId, secondId] = decodePair(row.ids);
424
+ if (firstCollection === sourceName && firstId === id || secondCollection === sourceName && secondId === id) deleteLinkRow(row.id);
425
+ }
426
+ }
217
427
 
218
428
  //#endregion
219
- export { createID, source };
429
+ export { createDatastore, createID, source };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "silosdk",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "tsdown",
@@ -17,6 +17,7 @@
17
17
  "@tanstack/react-query": "^5.101.0",
18
18
  "@types/node": "^25.3.0",
19
19
  "@types/react": "^19",
20
+ "expo-crypto": "^56.0.4",
20
21
  "expo-file-system": "^56.0.8",
21
22
  "expo-sqlite": "*",
22
23
  "prettier": "^3.7.4",
@@ -26,12 +27,12 @@
26
27
  "vitest": "^4.0.18"
27
28
  },
28
29
  "dependencies": {
29
- "@standard-schema/spec": "^1.0.0",
30
- "nanoid": "5.1.6"
30
+ "@standard-schema/spec": "^1.0.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@tanstack/react-db": "^0.1.86",
34
34
  "@tanstack/react-query": "^5.101.0",
35
+ "expo-crypto": "^56.0.4",
35
36
  "expo-file-system": "^56.0.8",
36
37
  "expo-sqlite": "*",
37
38
  "react": "^19",