silosdk 0.0.3 → 0.0.5

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 ADDED
@@ -0,0 +1,180 @@
1
+ # Silo SDK
2
+
3
+ Silo SDK provides local-first persistence primitives for Expo apps. It owns local
4
+ storage and returns TanStack-compatible option objects instead of wrapping
5
+ TanStack hooks.
6
+
7
+ Silo owns:
8
+
9
+ - SQLite persistence
10
+ - flat primitive document constraints
11
+ - defaults-first source shape
12
+ - settings storage
13
+ - media file ownership and resolution
14
+
15
+ TanStack owns:
16
+
17
+ - `createCollection()` and live collection queries
18
+ - `useQuery()` and `useMutation()`
19
+ - query and mutation state
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ yarn add silosdk @tanstack/react-db @tanstack/react-query expo-sqlite expo-file-system
25
+ ```
26
+
27
+ ## Sources
28
+
29
+ Sources are SQLite-backed document stores that produce options for TanStack DB
30
+ collections.
31
+
32
+ ```ts
33
+ import { createCollection } from '@tanstack/react-db'
34
+ import { createID, source } from 'silosdk/source'
35
+
36
+ type Post = {
37
+ title: string
38
+ body: string
39
+ published: boolean
40
+ coverImage: string | null
41
+ }
42
+
43
+ export const postsSource = source<Post>('posts', {
44
+ title: '',
45
+ body: '',
46
+ published: false,
47
+ coverImage: null,
48
+ })
49
+
50
+ export const posts = createCollection(postsSource.collectionOptions())
51
+ ```
52
+
53
+ Query and mutate with TanStack DB directly:
54
+
55
+ ```tsx
56
+ import { eq, useLiveQuery } from '@tanstack/react-db'
57
+
58
+ const { data: docs = [] } = useLiveQuery((q) =>
59
+ q.from({ posts }).where(({ posts }) => eq(posts.published, true)),
60
+ )
61
+
62
+ const doc = docs[0]
63
+ const post = doc?.data()
64
+
65
+ doc?.id
66
+ post?.title
67
+ ```
68
+
69
+ ```ts
70
+ const id = createID()
71
+
72
+ const tx = posts.insert({
73
+ id,
74
+ data: {
75
+ title: 'Hello',
76
+ body: '',
77
+ published: false,
78
+ coverImage: null,
79
+ },
80
+ })
81
+
82
+ await tx.isPersisted.promise
83
+
84
+ await posts.update(id, (doc) => {
85
+ doc.set({ published: true })
86
+ }).isPersisted.promise
87
+
88
+ await posts.delete(id).isPersisted.promise
89
+ ```
90
+
91
+ Source rows are intentionally flat. Field values must be:
92
+
93
+ ```ts
94
+ type FieldValue = string | number | boolean | null
95
+ ```
96
+
97
+ ## Settings
98
+
99
+ Settings are small persisted primitive values backed by `expo-sqlite/kv-store`.
100
+ They produce TanStack Query options.
101
+
102
+ ```ts
103
+ import { setting } from 'silosdk/settings'
104
+
105
+ export const theme = setting<'system' | 'light' | 'dark'>('theme', 'system')
106
+ export const reduceMotion = setting('reduceMotion', false)
107
+ ```
108
+
109
+ ```tsx
110
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
111
+
112
+ const queryClient = useQueryClient()
113
+ const { data: value } = useQuery(theme.queryOptions())
114
+ const setTheme = useMutation(theme.mutationOptions({ queryClient }))
115
+
116
+ setTheme.mutate('dark')
117
+ setTheme.mutate((current) => (current === 'dark' ? 'light' : 'dark'))
118
+ ```
119
+
120
+ Setting values must also be flat primitives:
121
+
122
+ ```ts
123
+ type SettingValue = string | number | boolean | null
124
+ ```
125
+
126
+ ## Media
127
+
128
+ Media is singleton infrastructure for app-owned files. Domain documents store
129
+ stable media refs as plain strings, keeping source rows flat.
130
+
131
+ ```ts
132
+ import { media, type MediaRef } from 'silosdk/media'
133
+ ```
134
+
135
+ Import a file with TanStack Query mutation options:
136
+
137
+ ```tsx
138
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
139
+
140
+ const queryClient = useQueryClient()
141
+ const importMedia = useMutation(media.mutationOptions({ queryClient }))
142
+
143
+ const coverImage = await importMedia.mutateAsync({
144
+ uri: pickedFile.uri,
145
+ kind: 'image',
146
+ name: pickedFile.name,
147
+ mimeType: pickedFile.type,
148
+ })
149
+ ```
150
+
151
+ Store the returned `media://...` ref in source rows:
152
+
153
+ ```ts
154
+ posts.update(postId, (draft) => {
155
+ draft.set({ coverImage })
156
+ })
157
+ ```
158
+
159
+ Resolve a media ref to a local file URI:
160
+
161
+ ```tsx
162
+ const { data: uri } = useQuery(media.queryOptions(post.coverImage))
163
+ ```
164
+
165
+ `media.queryOptions()` returns `string | null`. It returns `null` when the ref is
166
+ empty, invalid, unknown, or missing on disk.
167
+
168
+ ## API Shape
169
+
170
+ Silo exposes definition objects and option factories:
171
+
172
+ ```ts
173
+ createCollection(postsSource.collectionOptions())
174
+ useQuery(theme.queryOptions())
175
+ useMutation(theme.mutationOptions({ queryClient }))
176
+ useQuery(media.queryOptions(ref))
177
+ useMutation(media.mutationOptions({ queryClient }))
178
+ ```
179
+
180
+ There is no Silo provider and no Silo hook wrapper API.
package/dist/media.cjs ADDED
@@ -0,0 +1,135 @@
1
+ const require_rolldown_runtime = require('./_virtual/rolldown_runtime.cjs');
2
+ let expo_sqlite = require("expo-sqlite");
3
+ let nanoid = require("nanoid");
4
+ let __tanstack_react_query = require("@tanstack/react-query");
5
+ let expo_file_system = require("expo-file-system");
6
+
7
+ //#region src/media/index.ts
8
+ const media = {
9
+ queryOptions(ref) {
10
+ return (0, __tanstack_react_query.queryOptions)({
11
+ queryKey: mediaQueryKey(ref),
12
+ queryFn: () => resolveMediaUri(ref)
13
+ });
14
+ },
15
+ mutationOptions(options = {}) {
16
+ return (0, __tanstack_react_query.mutationOptions)({
17
+ mutationKey: [
18
+ "silo",
19
+ "media",
20
+ "import"
21
+ ],
22
+ mutationFn: async (input) => {
23
+ const imported = await importMedia(input);
24
+ options.queryClient?.setQueryData(mediaQueryKey(imported.ref), imported.uri);
25
+ return imported.ref;
26
+ }
27
+ });
28
+ }
29
+ };
30
+ function isMediaRef(value) {
31
+ return typeof value === "string" && value.startsWith("media://");
32
+ }
33
+ function mediaQueryKey(ref) {
34
+ return [
35
+ "silo",
36
+ "media",
37
+ ref ?? null
38
+ ];
39
+ }
40
+ async function importMedia(input) {
41
+ assertMediaKind(input.kind);
42
+ const id = (0, nanoid.nanoid)();
43
+ const ref = createMediaRef(input.kind, id);
44
+ const source = new expo_file_system.File(input.uri);
45
+ const name = input.name ?? source.name ?? `${id}${source.extension}`;
46
+ const destinationDirectory = mediaDirectory(input.kind);
47
+ destinationDirectory.create({
48
+ idempotent: true,
49
+ intermediates: true
50
+ });
51
+ const destination = new expo_file_system.File(destinationDirectory, `${id}${extensionFor(name)}`);
52
+ await source.copy(destination);
53
+ const info = destination.info();
54
+ const mimeType = input.mimeType ?? (destination.type || null);
55
+ const size = info.size ?? destination.size;
56
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
57
+ insertMediaFile({
58
+ ref,
59
+ uri: destination.uri,
60
+ kind: input.kind,
61
+ name,
62
+ mimeType,
63
+ size,
64
+ createdAt: timestamp,
65
+ data: input.data ?? {}
66
+ });
67
+ return {
68
+ ref,
69
+ uri: destination.uri
70
+ };
71
+ }
72
+ function resolveMediaUri(ref) {
73
+ if (!isMediaRef(ref)) return null;
74
+ const row = getDatabase().getFirstSync(`SELECT ref, uri FROM media_files WHERE ref = ?`, [ref]);
75
+ if (!row) return null;
76
+ return new expo_file_system.File(row.uri).exists ? row.uri : null;
77
+ }
78
+ function createMediaRef(kind, id) {
79
+ return `media://silo/${kindDirectoryName(kind)}/${id}`;
80
+ }
81
+ function mediaDirectory(kind) {
82
+ return new expo_file_system.Directory(expo_file_system.Paths.document, "silo", "media", kindDirectoryName(kind));
83
+ }
84
+ function kindDirectoryName(kind) {
85
+ switch (kind) {
86
+ case "image": return "images";
87
+ case "video": return "videos";
88
+ case "audio": return "audio";
89
+ case "file": return "files";
90
+ }
91
+ }
92
+ function extensionFor(name) {
93
+ return name.match(/\.[A-Za-z0-9]+$/)?.[0] ?? "";
94
+ }
95
+ function assertMediaKind(kind) {
96
+ if (kind !== "image" && kind !== "video" && kind !== "audio" && kind !== "file") throw new Error("Media kind must be one of \"image\", \"video\", \"audio\", or \"file\".");
97
+ }
98
+ let database;
99
+ function getDatabase() {
100
+ if (!database) {
101
+ database = (0, expo_sqlite.openDatabaseSync)("silo.db");
102
+ database.execSync(`
103
+ PRAGMA journal_mode = WAL;
104
+
105
+ CREATE TABLE IF NOT EXISTS media_files (
106
+ ref TEXT PRIMARY KEY,
107
+ uri TEXT NOT NULL,
108
+ kind TEXT NOT NULL,
109
+ name TEXT NOT NULL,
110
+ mimeType TEXT,
111
+ size INTEGER NOT NULL,
112
+ createdAt TEXT NOT NULL,
113
+ data TEXT NOT NULL
114
+ );
115
+ `);
116
+ }
117
+ return database;
118
+ }
119
+ function insertMediaFile(file) {
120
+ getDatabase().runSync(`INSERT INTO media_files (ref, uri, kind, name, mimeType, size, createdAt, data)
121
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
122
+ file.ref,
123
+ file.uri,
124
+ file.kind,
125
+ file.name,
126
+ file.mimeType,
127
+ file.size,
128
+ file.createdAt,
129
+ JSON.stringify(file.data)
130
+ ]);
131
+ }
132
+
133
+ //#endregion
134
+ exports.isMediaRef = isMediaRef;
135
+ exports.media = media;
@@ -0,0 +1,29 @@
1
+ import { QueryClient, UseMutationOptions, UseQueryOptions } from "@tanstack/react-query";
2
+
3
+ //#region src/media/index.d.ts
4
+ type MediaRef = `media://${string}`;
5
+ type MediaKind = 'image' | 'video' | 'audio' | 'file';
6
+ type MediaJson = string | number | boolean | null | MediaJson[] | {
7
+ [key: string]: MediaJson;
8
+ };
9
+ type MediaData = {
10
+ [key: string]: MediaJson;
11
+ };
12
+ type MediaImportInput = {
13
+ uri: string;
14
+ kind: MediaKind;
15
+ name?: string;
16
+ mimeType?: string | null;
17
+ data?: MediaData;
18
+ };
19
+ type MediaQueryKey = readonly ['silo', 'media', MediaRef | string | null];
20
+ type MediaMutationOptions = {
21
+ queryClient?: QueryClient;
22
+ };
23
+ declare const media: {
24
+ queryOptions(ref: MediaRef | string | null | undefined): UseQueryOptions<string | null, Error, string | null, MediaQueryKey>;
25
+ mutationOptions(options?: MediaMutationOptions): UseMutationOptions<MediaRef, Error, MediaImportInput>;
26
+ };
27
+ declare function isMediaRef(value: unknown): value is MediaRef;
28
+ //#endregion
29
+ export { MediaData, MediaImportInput, MediaJson, MediaKind, MediaMutationOptions, MediaQueryKey, MediaRef, isMediaRef, media };
@@ -0,0 +1,29 @@
1
+ import { QueryClient, UseMutationOptions, UseQueryOptions } from "@tanstack/react-query";
2
+
3
+ //#region src/media/index.d.ts
4
+ type MediaRef = `media://${string}`;
5
+ type MediaKind = 'image' | 'video' | 'audio' | 'file';
6
+ type MediaJson = string | number | boolean | null | MediaJson[] | {
7
+ [key: string]: MediaJson;
8
+ };
9
+ type MediaData = {
10
+ [key: string]: MediaJson;
11
+ };
12
+ type MediaImportInput = {
13
+ uri: string;
14
+ kind: MediaKind;
15
+ name?: string;
16
+ mimeType?: string | null;
17
+ data?: MediaData;
18
+ };
19
+ type MediaQueryKey = readonly ['silo', 'media', MediaRef | string | null];
20
+ type MediaMutationOptions = {
21
+ queryClient?: QueryClient;
22
+ };
23
+ declare const media: {
24
+ queryOptions(ref: MediaRef | string | null | undefined): UseQueryOptions<string | null, Error, string | null, MediaQueryKey>;
25
+ mutationOptions(options?: MediaMutationOptions): UseMutationOptions<MediaRef, Error, MediaImportInput>;
26
+ };
27
+ declare function isMediaRef(value: unknown): value is MediaRef;
28
+ //#endregion
29
+ export { MediaData, MediaImportInput, MediaJson, MediaKind, MediaMutationOptions, MediaQueryKey, MediaRef, isMediaRef, media };
package/dist/media.mjs ADDED
@@ -0,0 +1,133 @@
1
+ import { openDatabaseSync } from "expo-sqlite";
2
+ import { nanoid } from "nanoid";
3
+ import { mutationOptions, queryOptions } from "@tanstack/react-query";
4
+ import { Directory, File, Paths } from "expo-file-system";
5
+
6
+ //#region src/media/index.ts
7
+ const media = {
8
+ queryOptions(ref) {
9
+ return queryOptions({
10
+ queryKey: mediaQueryKey(ref),
11
+ queryFn: () => resolveMediaUri(ref)
12
+ });
13
+ },
14
+ mutationOptions(options = {}) {
15
+ return mutationOptions({
16
+ mutationKey: [
17
+ "silo",
18
+ "media",
19
+ "import"
20
+ ],
21
+ mutationFn: async (input) => {
22
+ const imported = await importMedia(input);
23
+ options.queryClient?.setQueryData(mediaQueryKey(imported.ref), imported.uri);
24
+ return imported.ref;
25
+ }
26
+ });
27
+ }
28
+ };
29
+ function isMediaRef(value) {
30
+ return typeof value === "string" && value.startsWith("media://");
31
+ }
32
+ function mediaQueryKey(ref) {
33
+ return [
34
+ "silo",
35
+ "media",
36
+ ref ?? null
37
+ ];
38
+ }
39
+ async function importMedia(input) {
40
+ assertMediaKind(input.kind);
41
+ const id = nanoid();
42
+ const ref = createMediaRef(input.kind, id);
43
+ const source = new File(input.uri);
44
+ const name = input.name ?? source.name ?? `${id}${source.extension}`;
45
+ const destinationDirectory = mediaDirectory(input.kind);
46
+ destinationDirectory.create({
47
+ idempotent: true,
48
+ intermediates: true
49
+ });
50
+ const destination = new File(destinationDirectory, `${id}${extensionFor(name)}`);
51
+ await source.copy(destination);
52
+ const info = destination.info();
53
+ const mimeType = input.mimeType ?? (destination.type || null);
54
+ const size = info.size ?? destination.size;
55
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
56
+ insertMediaFile({
57
+ ref,
58
+ uri: destination.uri,
59
+ kind: input.kind,
60
+ name,
61
+ mimeType,
62
+ size,
63
+ createdAt: timestamp,
64
+ data: input.data ?? {}
65
+ });
66
+ return {
67
+ ref,
68
+ uri: destination.uri
69
+ };
70
+ }
71
+ function resolveMediaUri(ref) {
72
+ if (!isMediaRef(ref)) return null;
73
+ const row = getDatabase().getFirstSync(`SELECT ref, uri FROM media_files WHERE ref = ?`, [ref]);
74
+ if (!row) return null;
75
+ return new File(row.uri).exists ? row.uri : null;
76
+ }
77
+ function createMediaRef(kind, id) {
78
+ return `media://silo/${kindDirectoryName(kind)}/${id}`;
79
+ }
80
+ function mediaDirectory(kind) {
81
+ return new Directory(Paths.document, "silo", "media", kindDirectoryName(kind));
82
+ }
83
+ function kindDirectoryName(kind) {
84
+ switch (kind) {
85
+ case "image": return "images";
86
+ case "video": return "videos";
87
+ case "audio": return "audio";
88
+ case "file": return "files";
89
+ }
90
+ }
91
+ function extensionFor(name) {
92
+ return name.match(/\.[A-Za-z0-9]+$/)?.[0] ?? "";
93
+ }
94
+ function assertMediaKind(kind) {
95
+ if (kind !== "image" && kind !== "video" && kind !== "audio" && kind !== "file") throw new Error("Media kind must be one of \"image\", \"video\", \"audio\", or \"file\".");
96
+ }
97
+ let database;
98
+ function getDatabase() {
99
+ if (!database) {
100
+ database = openDatabaseSync("silo.db");
101
+ database.execSync(`
102
+ PRAGMA journal_mode = WAL;
103
+
104
+ CREATE TABLE IF NOT EXISTS media_files (
105
+ ref TEXT PRIMARY KEY,
106
+ uri TEXT NOT NULL,
107
+ kind TEXT NOT NULL,
108
+ name TEXT NOT NULL,
109
+ mimeType TEXT,
110
+ size INTEGER NOT NULL,
111
+ createdAt TEXT NOT NULL,
112
+ data TEXT NOT NULL
113
+ );
114
+ `);
115
+ }
116
+ return database;
117
+ }
118
+ function insertMediaFile(file) {
119
+ getDatabase().runSync(`INSERT INTO media_files (ref, uri, kind, name, mimeType, size, createdAt, data)
120
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
121
+ file.ref,
122
+ file.uri,
123
+ file.kind,
124
+ file.name,
125
+ file.mimeType,
126
+ file.size,
127
+ file.createdAt,
128
+ JSON.stringify(file.data)
129
+ ]);
130
+ }
131
+
132
+ //#endregion
133
+ export { isMediaRef, media };
package/dist/source.cjs CHANGED
@@ -1,9 +1,19 @@
1
1
  const require_rolldown_runtime = require('./_virtual/rolldown_runtime.cjs');
2
2
  let expo_sqlite = require("expo-sqlite");
3
+ let nanoid = require("nanoid");
3
4
 
4
5
  //#region src/source/index.ts
5
6
  const sourceNames = /* @__PURE__ */ new Set();
6
7
  const sourceNamePattern = /^[A-Za-z][A-Za-z0-9_-]*$/;
8
+ const reservedDocumentFields = new Set([
9
+ "id",
10
+ "data",
11
+ "set"
12
+ ]);
13
+ const deletedDocs = /* @__PURE__ */ new WeakSet();
14
+ function createID() {
15
+ return (0, nanoid.nanoid)();
16
+ }
7
17
  function source(name, defaults) {
8
18
  assertSourceName(name);
9
19
  assertFlatDefaults(name, defaults);
@@ -38,7 +48,7 @@ function source(name, defaults) {
38
48
  await updateRows(name, transaction.mutations);
39
49
  },
40
50
  onDelete: async ({ transaction }) => {
41
- await deleteRows(name, transaction.mutations.map((mutation) => mutation.key));
51
+ await deleteRows(name, transaction.mutations);
42
52
  }
43
53
  };
44
54
  }
@@ -48,7 +58,10 @@ function assertSourceName(name) {
48
58
  if (!sourceNamePattern.test(name)) throw new Error(`Source names must start with a letter and contain only letters, numbers, underscores, or hyphens. Received "${name}".`);
49
59
  }
50
60
  function assertFlatDefaults(sourceName, defaults) {
51
- for (const [field, defaultValue] of Object.entries(defaults)) if (!isFieldValue(resolveDefault(defaultValue))) throw new Error(`Source "${sourceName}" field "${field}" must default to a string, number, boolean, null, or a function returning one of those values.`);
61
+ for (const [field, defaultValue] of Object.entries(defaults)) {
62
+ if (reservedDocumentFields.has(field)) throw new Error(`Source "${sourceName}" field "${field}" is reserved by Silo documents.`);
63
+ if (!isFieldValue(resolveDefault(defaultValue))) throw new Error(`Source "${sourceName}" field "${field}" must default to a string, number, boolean, null, or a function returning one of those values.`);
64
+ }
52
65
  }
53
66
  function createSourceSchema(sourceName, defaults) {
54
67
  return { "~standard": {
@@ -56,21 +69,64 @@ function createSourceSchema(sourceName, defaults) {
56
69
  vendor: "silo",
57
70
  validate(value) {
58
71
  if (!isRecord(value)) return failure("Expected a flat source row object.");
59
- if (typeof value.id !== "string") return failure("Expected source row field \"id\" to be a string.", ["id"]);
60
- const row = { id: value.id };
61
- for (const [field, fieldValue] of Object.entries(value)) {
62
- if (field === "id") continue;
63
- if (!isFieldValue(fieldValue)) return failure(`Source "${sourceName}" field "${field}" must be a string, number, boolean, or null.`, [field]);
64
- row[field] = fieldValue;
72
+ if (typeof value.id !== "string") return failure("Expected source document field \"id\" to be a string.", ["id"]);
73
+ const inputData = isRecord(value.data) ? value.data : extractDocumentData(value);
74
+ const data = {};
75
+ for (const [field, fieldValue] of Object.entries(inputData)) {
76
+ if (reservedDocumentFields.has(field)) return failure(`Source "${sourceName}" field "${field}" is reserved by Silo documents.`, ["data", field]);
77
+ if (!isFieldValue(fieldValue)) return failure(`Source "${sourceName}" field "${field}" must be a string, number, boolean, or null.`, ["data", field]);
78
+ data[field] = fieldValue;
65
79
  }
66
80
  for (const [field, defaultValue] of Object.entries(defaults)) {
67
- if (field in row) continue;
68
- row[field] = resolveDefault(defaultValue);
81
+ if (field in data) continue;
82
+ data[field] = resolveDefault(defaultValue);
69
83
  }
70
- return { value: row };
84
+ return { value: createDoc(value.id, data) };
71
85
  }
72
86
  } };
73
87
  }
88
+ function createDoc(id, data) {
89
+ const doc = {};
90
+ Object.defineProperty(doc, "id", {
91
+ value: id,
92
+ enumerable: true,
93
+ writable: false,
94
+ configurable: false
95
+ });
96
+ for (const [field, value] of Object.entries(data)) Object.defineProperty(doc, field, {
97
+ value,
98
+ enumerable: true,
99
+ writable: true,
100
+ configurable: true
101
+ });
102
+ Object.defineProperty(doc, "data", {
103
+ value() {
104
+ return deletedDocs.has(this) ? null : extractDocumentData(this);
105
+ },
106
+ enumerable: true,
107
+ writable: false,
108
+ configurable: false
109
+ });
110
+ Object.defineProperty(doc, "set", {
111
+ get() {
112
+ return function setDocumentData(patch) {
113
+ Object.assign(this, patch);
114
+ };
115
+ },
116
+ enumerable: true,
117
+ configurable: false
118
+ });
119
+ return doc;
120
+ }
121
+ function extractDocumentData(value) {
122
+ if (!isRecord(value)) return {};
123
+ const data = {};
124
+ for (const [field, fieldValue] of Object.entries(value)) {
125
+ if (reservedDocumentFields.has(field) || field.startsWith("$") || typeof fieldValue === "function") continue;
126
+ if (isFieldValue(fieldValue)) data[field] = fieldValue;
127
+ }
128
+ return data;
129
+ }
74
130
  function resolveDefault(value) {
75
131
  const resolved = typeof value === "function" ? value() : value;
76
132
  if (!isFieldValue(resolved)) throw new Error("Source defaults must resolve to a string, number, boolean, or null.");
@@ -109,21 +165,18 @@ function getDatabase() {
109
165
  return database;
110
166
  }
111
167
  function loadRows(sourceName) {
112
- return getDatabase().getAllSync(`SELECT id, data FROM sources WHERE source = ?`, [sourceName]).map((row) => ({
113
- id: row.id,
114
- ...JSON.parse(row.data)
115
- }));
168
+ return getDatabase().getAllSync(`SELECT id, data FROM sources WHERE source = ?`, [sourceName]).map((row) => createDoc(row.id, JSON.parse(row.data)));
116
169
  }
117
170
  function insertRows(sourceName, rows) {
118
171
  const db = getDatabase();
119
172
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
120
173
  db.withTransactionSync(() => {
121
174
  for (const row of rows) {
122
- const { id, ...data } = row;
175
+ const data = extractDocumentData(row);
123
176
  db.runSync(`INSERT INTO sources (source, id, data, createdAt, updatedAt, version)
124
177
  VALUES (?, ?, ?, ?, ?, 1)`, [
125
178
  sourceName,
126
- id,
179
+ row.id,
127
180
  JSON.stringify(data),
128
181
  timestamp,
129
182
  timestamp
@@ -137,7 +190,8 @@ function updateRows(sourceName, mutations) {
137
190
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
138
191
  db.withTransactionSync(() => {
139
192
  for (const mutation of mutations) {
140
- const { id, ...data } = mutation.modified;
193
+ const data = extractDocumentData(mutation.modified);
194
+ mutation.modified = createDoc(String(mutation.key), data);
141
195
  db.runSync(`UPDATE sources
142
196
  SET data = ?, updatedAt = ?, version = version + 1
143
197
  WHERE source = ? AND id = ?`, [
@@ -150,13 +204,18 @@ function updateRows(sourceName, mutations) {
150
204
  });
151
205
  return Promise.resolve();
152
206
  }
153
- function deleteRows(sourceName, ids) {
207
+ function deleteRows(sourceName, mutations) {
154
208
  const db = getDatabase();
155
209
  db.withTransactionSync(() => {
156
- for (const id of ids) db.runSync(`DELETE FROM sources WHERE source = ? AND id = ?`, [sourceName, String(id)]);
210
+ for (const mutation of mutations) {
211
+ if (mutation.original) deletedDocs.add(mutation.original);
212
+ deletedDocs.add(mutation.modified);
213
+ db.runSync(`DELETE FROM sources WHERE source = ? AND id = ?`, [sourceName, String(mutation.key)]);
214
+ }
157
215
  });
158
216
  return Promise.resolve();
159
217
  }
160
218
 
161
219
  //#endregion
220
+ exports.createID = createID;
162
221
  exports.source = source;
package/dist/source.d.cts CHANGED
@@ -9,21 +9,28 @@ type InvalidDocumentKeys<T extends object> = { [K in keyof T]-?: Exclude<T[K], u
9
9
  type DocumentData<T extends object> = InvalidDocumentKeys<T> extends never ? T : never;
10
10
  type DefaultValue<T> = Exclude<T, undefined> | (() => Exclude<T, undefined>);
11
11
  type SourceDefaults<T extends object> = { [K in RequiredKeys<DocumentData<T>>]: DefaultValue<DocumentData<T>[K]> } & { [K in OptionalKeys<DocumentData<T>>]?: DefaultValue<DocumentData<T>[K]> };
12
- type SourceRow<T extends object> = {
13
- id: string;
14
- } & DocumentData<T>;
15
12
  type SourceInput<T extends object> = {
16
13
  id: string;
17
- } & Partial<DocumentData<T>>;
14
+ data: Partial<DocumentData<T>>;
15
+ };
16
+ type Doc<T extends object> = {
17
+ readonly id: string;
18
+ data(): DocumentData<T> | null;
19
+ readonly set: (patch: Partial<DocumentData<T>>) => void;
20
+ } & DocumentData<T>;
21
+ type SourceRow<T extends object> = Doc<T>;
18
22
  type SourceCollectionOptions<T extends object> = {
19
23
  startSync?: boolean;
20
24
  };
21
25
  type Source<T extends object> = {
22
26
  readonly name: string;
23
27
  readonly defaults: SourceDefaults<T>;
24
- readonly schema: StandardSchemaV1<SourceInput<T>, SourceRow<T>>;
25
- collectionOptions(options?: SourceCollectionOptions<T>): CollectionConfig<SourceRow<T>, string, StandardSchemaV1<SourceInput<T>, SourceRow<T>>>;
28
+ readonly schema: StandardSchemaV1<any, SourceRow<T>>;
29
+ collectionOptions(options?: SourceCollectionOptions<T>): CollectionConfig<SourceRow<T>, string, StandardSchemaV1<any, SourceRow<T>>> & {
30
+ schema: StandardSchemaV1<any, SourceRow<T>>;
31
+ };
26
32
  };
33
+ declare function createID(): string;
27
34
  declare function source<T extends object>(name: string, defaults: SourceDefaults<T>): Source<T>;
28
35
  //#endregion
29
- export { DefaultValue, DocumentData, FieldValue, Source, SourceCollectionOptions, SourceDefaults, SourceInput, SourceRow, source };
36
+ export { DefaultValue, Doc, DocumentData, FieldValue, Source, SourceCollectionOptions, SourceDefaults, SourceInput, SourceRow, createID, source };
package/dist/source.d.mts CHANGED
@@ -9,21 +9,28 @@ type InvalidDocumentKeys<T extends object> = { [K in keyof T]-?: Exclude<T[K], u
9
9
  type DocumentData<T extends object> = InvalidDocumentKeys<T> extends never ? T : never;
10
10
  type DefaultValue<T> = Exclude<T, undefined> | (() => Exclude<T, undefined>);
11
11
  type SourceDefaults<T extends object> = { [K in RequiredKeys<DocumentData<T>>]: DefaultValue<DocumentData<T>[K]> } & { [K in OptionalKeys<DocumentData<T>>]?: DefaultValue<DocumentData<T>[K]> };
12
- type SourceRow<T extends object> = {
13
- id: string;
14
- } & DocumentData<T>;
15
12
  type SourceInput<T extends object> = {
16
13
  id: string;
17
- } & Partial<DocumentData<T>>;
14
+ data: Partial<DocumentData<T>>;
15
+ };
16
+ type Doc<T extends object> = {
17
+ readonly id: string;
18
+ data(): DocumentData<T> | null;
19
+ readonly set: (patch: Partial<DocumentData<T>>) => void;
20
+ } & DocumentData<T>;
21
+ type SourceRow<T extends object> = Doc<T>;
18
22
  type SourceCollectionOptions<T extends object> = {
19
23
  startSync?: boolean;
20
24
  };
21
25
  type Source<T extends object> = {
22
26
  readonly name: string;
23
27
  readonly defaults: SourceDefaults<T>;
24
- readonly schema: StandardSchemaV1<SourceInput<T>, SourceRow<T>>;
25
- collectionOptions(options?: SourceCollectionOptions<T>): CollectionConfig<SourceRow<T>, string, StandardSchemaV1<SourceInput<T>, SourceRow<T>>>;
28
+ readonly schema: StandardSchemaV1<any, SourceRow<T>>;
29
+ collectionOptions(options?: SourceCollectionOptions<T>): CollectionConfig<SourceRow<T>, string, StandardSchemaV1<any, SourceRow<T>>> & {
30
+ schema: StandardSchemaV1<any, SourceRow<T>>;
31
+ };
26
32
  };
33
+ declare function createID(): string;
27
34
  declare function source<T extends object>(name: string, defaults: SourceDefaults<T>): Source<T>;
28
35
  //#endregion
29
- export { DefaultValue, DocumentData, FieldValue, Source, SourceCollectionOptions, SourceDefaults, SourceInput, SourceRow, source };
36
+ export { DefaultValue, Doc, DocumentData, FieldValue, Source, SourceCollectionOptions, SourceDefaults, SourceInput, SourceRow, createID, source };
package/dist/source.mjs CHANGED
@@ -1,8 +1,18 @@
1
1
  import { openDatabaseSync } from "expo-sqlite";
2
+ import { nanoid } from "nanoid";
2
3
 
3
4
  //#region src/source/index.ts
4
5
  const sourceNames = /* @__PURE__ */ new Set();
5
6
  const sourceNamePattern = /^[A-Za-z][A-Za-z0-9_-]*$/;
7
+ const reservedDocumentFields = new Set([
8
+ "id",
9
+ "data",
10
+ "set"
11
+ ]);
12
+ const deletedDocs = /* @__PURE__ */ new WeakSet();
13
+ function createID() {
14
+ return nanoid();
15
+ }
6
16
  function source(name, defaults) {
7
17
  assertSourceName(name);
8
18
  assertFlatDefaults(name, defaults);
@@ -37,7 +47,7 @@ function source(name, defaults) {
37
47
  await updateRows(name, transaction.mutations);
38
48
  },
39
49
  onDelete: async ({ transaction }) => {
40
- await deleteRows(name, transaction.mutations.map((mutation) => mutation.key));
50
+ await deleteRows(name, transaction.mutations);
41
51
  }
42
52
  };
43
53
  }
@@ -47,7 +57,10 @@ function assertSourceName(name) {
47
57
  if (!sourceNamePattern.test(name)) throw new Error(`Source names must start with a letter and contain only letters, numbers, underscores, or hyphens. Received "${name}".`);
48
58
  }
49
59
  function assertFlatDefaults(sourceName, defaults) {
50
- for (const [field, defaultValue] of Object.entries(defaults)) if (!isFieldValue(resolveDefault(defaultValue))) throw new Error(`Source "${sourceName}" field "${field}" must default to a string, number, boolean, null, or a function returning one of those values.`);
60
+ for (const [field, defaultValue] of Object.entries(defaults)) {
61
+ if (reservedDocumentFields.has(field)) throw new Error(`Source "${sourceName}" field "${field}" is reserved by Silo documents.`);
62
+ if (!isFieldValue(resolveDefault(defaultValue))) throw new Error(`Source "${sourceName}" field "${field}" must default to a string, number, boolean, null, or a function returning one of those values.`);
63
+ }
51
64
  }
52
65
  function createSourceSchema(sourceName, defaults) {
53
66
  return { "~standard": {
@@ -55,21 +68,64 @@ function createSourceSchema(sourceName, defaults) {
55
68
  vendor: "silo",
56
69
  validate(value) {
57
70
  if (!isRecord(value)) return failure("Expected a flat source row object.");
58
- if (typeof value.id !== "string") return failure("Expected source row field \"id\" to be a string.", ["id"]);
59
- const row = { id: value.id };
60
- for (const [field, fieldValue] of Object.entries(value)) {
61
- if (field === "id") continue;
62
- if (!isFieldValue(fieldValue)) return failure(`Source "${sourceName}" field "${field}" must be a string, number, boolean, or null.`, [field]);
63
- row[field] = fieldValue;
71
+ if (typeof value.id !== "string") return failure("Expected source document field \"id\" to be a string.", ["id"]);
72
+ const inputData = isRecord(value.data) ? value.data : extractDocumentData(value);
73
+ const data = {};
74
+ for (const [field, fieldValue] of Object.entries(inputData)) {
75
+ if (reservedDocumentFields.has(field)) return failure(`Source "${sourceName}" field "${field}" is reserved by Silo documents.`, ["data", field]);
76
+ if (!isFieldValue(fieldValue)) return failure(`Source "${sourceName}" field "${field}" must be a string, number, boolean, or null.`, ["data", field]);
77
+ data[field] = fieldValue;
64
78
  }
65
79
  for (const [field, defaultValue] of Object.entries(defaults)) {
66
- if (field in row) continue;
67
- row[field] = resolveDefault(defaultValue);
80
+ if (field in data) continue;
81
+ data[field] = resolveDefault(defaultValue);
68
82
  }
69
- return { value: row };
83
+ return { value: createDoc(value.id, data) };
70
84
  }
71
85
  } };
72
86
  }
87
+ function createDoc(id, data) {
88
+ const doc = {};
89
+ Object.defineProperty(doc, "id", {
90
+ value: id,
91
+ enumerable: true,
92
+ writable: false,
93
+ configurable: false
94
+ });
95
+ for (const [field, value] of Object.entries(data)) Object.defineProperty(doc, field, {
96
+ value,
97
+ enumerable: true,
98
+ writable: true,
99
+ configurable: true
100
+ });
101
+ Object.defineProperty(doc, "data", {
102
+ value() {
103
+ return deletedDocs.has(this) ? null : extractDocumentData(this);
104
+ },
105
+ enumerable: true,
106
+ writable: false,
107
+ configurable: false
108
+ });
109
+ Object.defineProperty(doc, "set", {
110
+ get() {
111
+ return function setDocumentData(patch) {
112
+ Object.assign(this, patch);
113
+ };
114
+ },
115
+ enumerable: true,
116
+ configurable: false
117
+ });
118
+ return doc;
119
+ }
120
+ function extractDocumentData(value) {
121
+ if (!isRecord(value)) return {};
122
+ const data = {};
123
+ for (const [field, fieldValue] of Object.entries(value)) {
124
+ if (reservedDocumentFields.has(field) || field.startsWith("$") || typeof fieldValue === "function") continue;
125
+ if (isFieldValue(fieldValue)) data[field] = fieldValue;
126
+ }
127
+ return data;
128
+ }
73
129
  function resolveDefault(value) {
74
130
  const resolved = typeof value === "function" ? value() : value;
75
131
  if (!isFieldValue(resolved)) throw new Error("Source defaults must resolve to a string, number, boolean, or null.");
@@ -108,21 +164,18 @@ function getDatabase() {
108
164
  return database;
109
165
  }
110
166
  function loadRows(sourceName) {
111
- return getDatabase().getAllSync(`SELECT id, data FROM sources WHERE source = ?`, [sourceName]).map((row) => ({
112
- id: row.id,
113
- ...JSON.parse(row.data)
114
- }));
167
+ return getDatabase().getAllSync(`SELECT id, data FROM sources WHERE source = ?`, [sourceName]).map((row) => createDoc(row.id, JSON.parse(row.data)));
115
168
  }
116
169
  function insertRows(sourceName, rows) {
117
170
  const db = getDatabase();
118
171
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
119
172
  db.withTransactionSync(() => {
120
173
  for (const row of rows) {
121
- const { id, ...data } = row;
174
+ const data = extractDocumentData(row);
122
175
  db.runSync(`INSERT INTO sources (source, id, data, createdAt, updatedAt, version)
123
176
  VALUES (?, ?, ?, ?, ?, 1)`, [
124
177
  sourceName,
125
- id,
178
+ row.id,
126
179
  JSON.stringify(data),
127
180
  timestamp,
128
181
  timestamp
@@ -136,7 +189,8 @@ function updateRows(sourceName, mutations) {
136
189
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
137
190
  db.withTransactionSync(() => {
138
191
  for (const mutation of mutations) {
139
- const { id, ...data } = mutation.modified;
192
+ const data = extractDocumentData(mutation.modified);
193
+ mutation.modified = createDoc(String(mutation.key), data);
140
194
  db.runSync(`UPDATE sources
141
195
  SET data = ?, updatedAt = ?, version = version + 1
142
196
  WHERE source = ? AND id = ?`, [
@@ -149,13 +203,17 @@ function updateRows(sourceName, mutations) {
149
203
  });
150
204
  return Promise.resolve();
151
205
  }
152
- function deleteRows(sourceName, ids) {
206
+ function deleteRows(sourceName, mutations) {
153
207
  const db = getDatabase();
154
208
  db.withTransactionSync(() => {
155
- for (const id of ids) db.runSync(`DELETE FROM sources WHERE source = ? AND id = ?`, [sourceName, String(id)]);
209
+ for (const mutation of mutations) {
210
+ if (mutation.original) deletedDocs.add(mutation.original);
211
+ deletedDocs.add(mutation.modified);
212
+ db.runSync(`DELETE FROM sources WHERE source = ? AND id = ?`, [sourceName, String(mutation.key)]);
213
+ }
156
214
  });
157
215
  return Promise.resolve();
158
216
  }
159
217
 
160
218
  //#endregion
161
- export { source };
219
+ export { createID, source };
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "silosdk",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "tsdown",
7
7
  "watch": "tsdown --watch",
8
8
  "test": "vitest run",
9
- "publish": "tsdown && npm publish"
9
+ "pub": "tsdown && npm publish"
10
10
  },
11
11
  "packageManager": "yarn@1.22.22",
12
12
  "files": [
@@ -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-file-system": "^56.0.8",
20
21
  "expo-sqlite": "*",
21
22
  "prettier": "^3.7.4",
22
23
  "react-native": "0.85.0",
@@ -26,11 +27,12 @@
26
27
  },
27
28
  "dependencies": {
28
29
  "@standard-schema/spec": "^1.0.0",
29
- "@tanstack/react-db": "^0.1.86",
30
- "@tanstack/react-query": "^5.101.0",
31
30
  "nanoid": "5.1.6"
32
31
  },
33
32
  "peerDependencies": {
33
+ "@tanstack/react-db": "^0.1.86",
34
+ "@tanstack/react-query": "^5.101.0",
35
+ "expo-file-system": "^56.0.8",
34
36
  "expo-sqlite": "*",
35
37
  "react": "^19",
36
38
  "react-native": "0.85.x"
@@ -56,6 +58,11 @@
56
58
  "import": "./dist/settings.mjs",
57
59
  "require": "./dist/settings.cjs"
58
60
  },
61
+ "./media": {
62
+ "types": "./dist/media.d.mts",
63
+ "import": "./dist/media.mjs",
64
+ "require": "./dist/media.cjs"
65
+ },
59
66
  "./package.json": "./package.json"
60
67
  }
61
68
  }