or3-provider-sqlite 0.0.1

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.
Files changed (32) hide show
  1. package/README.md +109 -0
  2. package/dist/module.d.mts +5 -0
  3. package/dist/module.json +9 -0
  4. package/dist/module.mjs +11 -0
  5. package/dist/runtime/server/admin/adapters/sync-sqlite.d.ts +2 -0
  6. package/dist/runtime/server/admin/adapters/sync-sqlite.js +72 -0
  7. package/dist/runtime/server/admin/stores/sqlite-store.d.ts +4 -0
  8. package/dist/runtime/server/admin/stores/sqlite-store.js +336 -0
  9. package/dist/runtime/server/auth/sqlite-auth-workspace-store.d.ts +111 -0
  10. package/dist/runtime/server/auth/sqlite-auth-workspace-store.js +349 -0
  11. package/dist/runtime/server/db/kysely.d.ts +32 -0
  12. package/dist/runtime/server/db/kysely.js +62 -0
  13. package/dist/runtime/server/db/migrate.d.ts +10 -0
  14. package/dist/runtime/server/db/migrate.js +38 -0
  15. package/dist/runtime/server/db/migrations/001_init.d.ts +6 -0
  16. package/dist/runtime/server/db/migrations/001_init.js +31 -0
  17. package/dist/runtime/server/db/migrations/002_sync_tables.d.ts +6 -0
  18. package/dist/runtime/server/db/migrations/002_sync_tables.js +55 -0
  19. package/dist/runtime/server/db/migrations/003_sync_hardening.d.ts +9 -0
  20. package/dist/runtime/server/db/migrations/003_sync_hardening.js +67 -0
  21. package/dist/runtime/server/db/migrations/004_auth_invites.d.ts +3 -0
  22. package/dist/runtime/server/db/migrations/004_auth_invites.js +18 -0
  23. package/dist/runtime/server/db/migrations/005_admin_stores.d.ts +7 -0
  24. package/dist/runtime/server/db/migrations/005_admin_stores.js +12 -0
  25. package/dist/runtime/server/db/schema.d.ts +138 -0
  26. package/dist/runtime/server/db/schema.js +10 -0
  27. package/dist/runtime/server/plugins/register.d.ts +2 -0
  28. package/dist/runtime/server/plugins/register.js +48 -0
  29. package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.d.ts +36 -0
  30. package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.js +366 -0
  31. package/dist/types.d.mts +7 -0
  32. package/package.json +54 -0
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # or3-provider-sqlite
2
+
3
+ SQLite sync and workspace store provider for OR3 Chat. Provides a lightweight, self-hosted alternative to Convex for SSR cloud mode.
4
+
5
+ ## What it provides
6
+
7
+ - **AuthWorkspaceStore** (`sqlite`) — user identity mapping, workspace CRUD, role resolution
8
+ - **SyncGatewayAdapter** (`sqlite`) — push/pull sync with LWW conflict resolution, cursor tracking, GC
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ bun add or3-provider-sqlite
14
+ ```
15
+
16
+ Add to your provider module list (e.g. `or3.providers.generated.ts`):
17
+
18
+ ```ts
19
+ export default ['or3-provider-sqlite/nuxt'];
20
+ ```
21
+
22
+ ## Configuration
23
+
24
+ ### Environment Variables
25
+
26
+ | Variable | Required | Default | Description |
27
+ |---|---|---|---|
28
+ | `OR3_SQLITE_DB_PATH` | Yes (non-test) | None | Path to SQLite database file |
29
+ | `OR3_SQLITE_PRAGMA_JOURNAL_MODE` | No | `WAL` | SQLite journal mode |
30
+ | `OR3_SQLITE_PRAGMA_SYNCHRONOUS` | No | `NORMAL` | SQLite synchronous pragma |
31
+ | `OR3_SQLITE_ALLOW_IN_MEMORY` | No | `false` | Allow `:memory:` in non-test environments (ephemeral data) |
32
+ | `OR3_SQLITE_STRICT` | No | `false` | Fail startup if `:memory:` is used |
33
+
34
+ ### Production example
35
+
36
+ ```bash
37
+ OR3_SQLITE_DB_PATH=/data/or3-sync.db
38
+ OR3_SQLITE_PRAGMA_JOURNAL_MODE=WAL
39
+ OR3_SQLITE_PRAGMA_SYNCHRONOUS=NORMAL
40
+ ```
41
+
42
+ ## How it works
43
+
44
+ ### Registration
45
+
46
+ On server startup, the Nitro plugin:
47
+
48
+ 1. Initializes the SQLite database (creates file if needed)
49
+ 2. Runs schema migrations automatically
50
+ 3. Registers `AuthWorkspaceStore` with ID `sqlite`
51
+ 4. Registers `SyncGatewayAdapter` with ID `sqlite`
52
+
53
+ Registration is skipped when `auth.enabled` is `false` (local-only mode).
54
+
55
+ ### Schema
56
+
57
+ Two migrations create all tables:
58
+
59
+ - **001_init**: `users`, `auth_accounts`, `workspaces`, `workspace_members`
60
+ - **002_sync_tables**: `server_version_counter`, `change_log`, `device_cursors`, `tombstones`, plus materialized entity tables (`s_threads`, `s_messages`, etc.)
61
+
62
+ All tables use snake_case aligned with the sync wire format.
63
+
64
+ ### Sync semantics
65
+
66
+ - **Push**: validates ops → checks `op_id` idempotency → allocates contiguous `server_version` block → applies LWW to materialized tables → writes change_log → upserts tombstones for deletes
67
+ - **Pull**: returns ordered changes for `server_version > cursor` with limit/pagination and optional table filtering
68
+ - **Cursor**: forward-only per-device cursor tracking
69
+ - **GC**: tombstone and change_log cleanup respects min device cursor + retention window
70
+
71
+ LWW conflict resolution: incoming wins when `clock` is higher, or when clocks are equal and `hlc` is lexicographically greater.
72
+
73
+ Push uses `BEGIN IMMEDIATE` transactions to prevent concurrent server_version races.
74
+
75
+ ### Workspace store
76
+
77
+ - `getOrCreateUser` — maps `(provider, provider_user_id)` to internal user (idempotent)
78
+ - `getOrCreateDefaultWorkspace` — creates first workspace + owner membership on initial login
79
+ - Full workspace CRUD with role-based access checks
80
+
81
+ ## Backup
82
+
83
+ Since everything lives in a single SQLite file:
84
+
85
+ ```bash
86
+ # While the app is running (WAL mode supports this)
87
+ sqlite3 /data/or3-sync.db ".backup /backup/or3-sync-$(date +%s).db"
88
+ ```
89
+
90
+ ## Development
91
+
92
+ ```bash
93
+ bun install
94
+ bun run test # run unit tests
95
+ bun run type-check # TypeScript validation
96
+ bun run build # build for distribution
97
+ ```
98
+
99
+ ## Compatibility
100
+
101
+ - Works with multiple auth providers (`basic-auth`, `clerk`, or custom)
102
+ - Replaces `or3-provider-convex` for sync + workspace store functionality
103
+ - Does NOT provide storage — pair with `or3-provider-fs` for file storage
104
+
105
+ ### Known differences vs Convex
106
+
107
+ - Single-process SQLite vs distributed Convex backend
108
+ - No real-time subscriptions (gateway polling only)
109
+ - Migrations run on boot; schema changes require restart
@@ -0,0 +1,5 @@
1
+ import * as _nuxt_schema from '@nuxt/schema';
2
+
3
+ declare const _default: _nuxt_schema.NuxtModule<Record<string, unknown>, Record<string, unknown>, false>;
4
+
5
+ export { _default as default };
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "or3-provider-sqlite",
3
+ "configKey": "or3-provider-sqlite",
4
+ "version": "0.0.1",
5
+ "builder": {
6
+ "@nuxt/module-builder": "1.0.2",
7
+ "unbuild": "3.6.1"
8
+ }
9
+ }
@@ -0,0 +1,11 @@
1
+ import { defineNuxtModule, createResolver, addServerPlugin } from '@nuxt/kit';
2
+
3
+ const module$1 = defineNuxtModule({
4
+ meta: { name: "or3-provider-sqlite" },
5
+ setup(_options, _nuxt) {
6
+ const { resolve } = createResolver(import.meta.url);
7
+ addServerPlugin(resolve("runtime/server/plugins/register"));
8
+ }
9
+ });
10
+
11
+ export { module$1 as default };
@@ -0,0 +1,2 @@
1
+ import type { ProviderAdminAdapter } from '~~/server/admin/providers/types';
2
+ export declare const sqliteSyncAdminAdapter: ProviderAdminAdapter;
@@ -0,0 +1,72 @@
1
+ import { createError } from "h3";
2
+ import { createSqliteSyncGatewayAdapter } from "../../sync/sqlite-sync-gateway-adapter.js";
3
+ const SQLITE_PROVIDER_ID = "sqlite";
4
+ const DEFAULT_RETENTION_SECONDS = 30 * 24 * 3600;
5
+ function resolveRetentionSeconds(payload) {
6
+ const days = typeof payload?.retentionDays === "number" ? payload.retentionDays : null;
7
+ const seconds = typeof payload?.retentionSeconds === "number" ? payload.retentionSeconds : null;
8
+ if (seconds && Number.isFinite(seconds) && seconds > 0) return Math.floor(seconds);
9
+ if (days && Number.isFinite(days) && days > 0) return Math.floor(days * 24 * 3600);
10
+ return DEFAULT_RETENTION_SECONDS;
11
+ }
12
+ export const sqliteSyncAdminAdapter = {
13
+ id: SQLITE_PROVIDER_ID,
14
+ kind: "sync",
15
+ async getStatus(_event, _ctx) {
16
+ const dbPath = process.env.OR3_SQLITE_DB_PATH;
17
+ const warnings = [];
18
+ if (!dbPath) {
19
+ warnings.push({
20
+ level: "warning",
21
+ message: "OR3_SQLITE_DB_PATH is not set. SQLite may run in ephemeral mode."
22
+ });
23
+ } else if (dbPath === ":memory:") {
24
+ warnings.push({
25
+ level: "warning",
26
+ message: "SQLite is configured with :memory:. Data will not persist across restarts."
27
+ });
28
+ }
29
+ return {
30
+ details: {
31
+ dbPath: dbPath ?? ":memory:",
32
+ journalMode: process.env.OR3_SQLITE_PRAGMA_JOURNAL_MODE ?? "WAL",
33
+ synchronous: process.env.OR3_SQLITE_PRAGMA_SYNCHRONOUS ?? "NORMAL"
34
+ },
35
+ warnings,
36
+ actions: [
37
+ {
38
+ id: "sync.gc-change-log",
39
+ label: "Run Sync Change Log GC",
40
+ description: "Purge old change_log entries after retention and device cursor checks.",
41
+ danger: true
42
+ },
43
+ {
44
+ id: "sync.gc-tombstones",
45
+ label: "Run Sync Tombstone GC",
46
+ description: "Purge old tombstones after retention and device cursor checks.",
47
+ danger: true
48
+ }
49
+ ]
50
+ };
51
+ },
52
+ async runAction(event, actionId, payload, ctx) {
53
+ if (!ctx.session.workspace?.id) {
54
+ throw createError({
55
+ statusCode: 400,
56
+ statusMessage: "Workspace not resolved"
57
+ });
58
+ }
59
+ const adapter = createSqliteSyncGatewayAdapter();
60
+ const retentionSeconds = resolveRetentionSeconds(payload);
61
+ const scope = { workspaceId: ctx.session.workspace.id };
62
+ if (actionId === "sync.gc-change-log") {
63
+ await adapter.gcChangeLog?.(event, { scope, retentionSeconds });
64
+ return { ok: true, action: actionId, retentionSeconds };
65
+ }
66
+ if (actionId === "sync.gc-tombstones") {
67
+ await adapter.gcTombstones?.(event, { scope, retentionSeconds });
68
+ return { ok: true, action: actionId, retentionSeconds };
69
+ }
70
+ throw createError({ statusCode: 400, statusMessage: "Unknown action" });
71
+ }
72
+ };
@@ -0,0 +1,4 @@
1
+ import type { AdminUserStore, WorkspaceAccessStore, WorkspaceSettingsStore } from '~~/server/admin/stores/types';
2
+ export declare function createSqliteWorkspaceAccessStore(): WorkspaceAccessStore;
3
+ export declare function createSqliteWorkspaceSettingsStore(): WorkspaceSettingsStore;
4
+ export declare function createSqliteAdminUserStore(): AdminUserStore;
@@ -0,0 +1,336 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getSqliteDb } from "../../db/kysely.js";
3
+ function nowEpoch() {
4
+ return Math.floor(Date.now() / 1e3);
5
+ }
6
+ function normalizeEmail(value) {
7
+ return value.trim().toLowerCase();
8
+ }
9
+ function isEmail(value) {
10
+ return value.includes("@");
11
+ }
12
+ class SqliteWorkspaceAccessStore {
13
+ get db() {
14
+ return getSqliteDb();
15
+ }
16
+ async listMembers(input) {
17
+ const rows = await this.db.selectFrom("workspace_members").innerJoin("users", "users.id", "workspace_members.user_id").select([
18
+ "workspace_members.user_id as user_id",
19
+ "workspace_members.role as role",
20
+ "users.email as email"
21
+ ]).where("workspace_members.workspace_id", "=", input.workspaceId).orderBy("workspace_members.created_at", "asc").execute();
22
+ return rows.map((row) => ({
23
+ userId: row.user_id,
24
+ email: row.email ?? void 0,
25
+ role: row.role
26
+ }));
27
+ }
28
+ async upsertMember(input) {
29
+ const workspace = await this.db.selectFrom("workspaces").select(["id", "deleted"]).where("id", "=", input.workspaceId).executeTakeFirst();
30
+ if (!workspace || workspace.deleted === 1) {
31
+ throw new Error("Workspace not found");
32
+ }
33
+ const lookup = input.emailOrProviderId.trim();
34
+ const provider = input.provider?.trim();
35
+ const now = nowEpoch();
36
+ let userId = null;
37
+ if (isEmail(lookup)) {
38
+ const normalized = normalizeEmail(lookup);
39
+ const existing = await this.db.selectFrom("users").select("id").where("email", "=", normalized).executeTakeFirst();
40
+ if (existing) {
41
+ userId = existing.id;
42
+ } else {
43
+ userId = randomUUID();
44
+ await this.db.insertInto("users").values({
45
+ id: userId,
46
+ email: normalized,
47
+ display_name: null,
48
+ active_workspace_id: null,
49
+ created_at: now
50
+ }).execute();
51
+ }
52
+ } else {
53
+ const account = provider ? await this.db.selectFrom("auth_accounts").select("user_id").where("provider", "=", provider).where("provider_user_id", "=", lookup).executeTakeFirst() : await this.db.selectFrom("auth_accounts").select("user_id").where("provider_user_id", "=", lookup).executeTakeFirst();
54
+ if (account) {
55
+ userId = account.user_id;
56
+ } else {
57
+ const byUserId = await this.db.selectFrom("users").select("id").where("id", "=", lookup).executeTakeFirst();
58
+ if (byUserId) {
59
+ userId = byUserId.id;
60
+ }
61
+ }
62
+ if (!userId) {
63
+ userId = randomUUID();
64
+ await this.db.insertInto("users").values({
65
+ id: userId,
66
+ email: null,
67
+ display_name: null,
68
+ active_workspace_id: null,
69
+ created_at: now
70
+ }).execute();
71
+ await this.db.insertInto("auth_accounts").values({
72
+ id: randomUUID(),
73
+ user_id: userId,
74
+ provider: provider || "custom",
75
+ provider_user_id: lookup,
76
+ created_at: now
77
+ }).onConflict(
78
+ (oc) => oc.columns(["provider", "provider_user_id"]).doNothing()
79
+ ).execute();
80
+ }
81
+ }
82
+ await this.db.insertInto("workspace_members").values({
83
+ id: randomUUID(),
84
+ workspace_id: input.workspaceId,
85
+ user_id: userId,
86
+ role: input.role,
87
+ created_at: now
88
+ }).onConflict(
89
+ (oc) => oc.columns(["workspace_id", "user_id"]).doUpdateSet({ role: input.role })
90
+ ).execute();
91
+ }
92
+ async setMemberRole(input) {
93
+ const result = await this.db.updateTable("workspace_members").set({ role: input.role }).where("workspace_id", "=", input.workspaceId).where("user_id", "=", input.userId).executeTakeFirst();
94
+ const updated = Number(result.numUpdatedRows ?? 0);
95
+ if (updated === 0) {
96
+ throw new Error("Workspace member not found");
97
+ }
98
+ }
99
+ async removeMember(input) {
100
+ await this.db.deleteFrom("workspace_members").where("workspace_id", "=", input.workspaceId).where("user_id", "=", input.userId).execute();
101
+ }
102
+ async listWorkspaces(input) {
103
+ const includeDeleted = input.includeDeleted === true;
104
+ const search = input.search?.trim();
105
+ let totalQuery = this.db.selectFrom("workspaces").select((eb) => eb.fn.countAll().as("count"));
106
+ let itemsQuery = this.db.selectFrom("workspaces").leftJoin("users as owners", "owners.id", "workspaces.owner_user_id").select([
107
+ "workspaces.id as id",
108
+ "workspaces.name as name",
109
+ "workspaces.description as description",
110
+ "workspaces.created_at as created_at",
111
+ "workspaces.deleted as deleted",
112
+ "workspaces.deleted_at as deleted_at",
113
+ "workspaces.owner_user_id as owner_user_id",
114
+ "owners.email as owner_email"
115
+ ]);
116
+ if (!includeDeleted) {
117
+ totalQuery = totalQuery.where("workspaces.deleted", "=", 0);
118
+ itemsQuery = itemsQuery.where("workspaces.deleted", "=", 0);
119
+ }
120
+ if (search) {
121
+ const pattern = `%${search}%`;
122
+ totalQuery = totalQuery.where(
123
+ (eb) => eb.or([
124
+ eb("workspaces.name", "like", pattern),
125
+ eb("workspaces.description", "like", pattern)
126
+ ])
127
+ );
128
+ itemsQuery = itemsQuery.where(
129
+ (eb) => eb.or([
130
+ eb("workspaces.name", "like", pattern),
131
+ eb("workspaces.description", "like", pattern)
132
+ ])
133
+ );
134
+ }
135
+ const totalRow = await totalQuery.executeTakeFirstOrThrow();
136
+ const total = Number(totalRow.count ?? 0);
137
+ const page = Math.max(1, input.page);
138
+ const perPage = Math.max(1, Math.min(input.perPage, 100));
139
+ const offset = (page - 1) * perPage;
140
+ const rows = await itemsQuery.orderBy("workspaces.created_at", "desc").limit(perPage).offset(offset).execute();
141
+ if (rows.length === 0) {
142
+ return { items: [], total };
143
+ }
144
+ const workspaceIds = rows.map((row) => row.id);
145
+ const counts = await this.db.selectFrom("workspace_members").select([
146
+ "workspace_id",
147
+ (eb) => eb.fn.countAll().as("member_count")
148
+ ]).where("workspace_id", "in", workspaceIds).groupBy("workspace_id").execute();
149
+ const countMap = new Map(
150
+ counts.map((row) => [row.workspace_id, Number(row.member_count ?? 0)])
151
+ );
152
+ return {
153
+ total,
154
+ items: rows.map((row) => ({
155
+ id: row.id,
156
+ name: row.name,
157
+ description: row.description ?? void 0,
158
+ createdAt: row.created_at,
159
+ deleted: row.deleted === 1,
160
+ deletedAt: row.deleted_at ?? void 0,
161
+ ownerUserId: row.owner_user_id ?? void 0,
162
+ ownerEmail: row.owner_email ?? void 0,
163
+ memberCount: countMap.get(row.id) ?? 0
164
+ }))
165
+ };
166
+ }
167
+ async getWorkspace(input) {
168
+ const row = await this.db.selectFrom("workspaces").leftJoin("users as owners", "owners.id", "workspaces.owner_user_id").select([
169
+ "workspaces.id as id",
170
+ "workspaces.name as name",
171
+ "workspaces.description as description",
172
+ "workspaces.created_at as created_at",
173
+ "workspaces.deleted as deleted",
174
+ "workspaces.deleted_at as deleted_at",
175
+ "workspaces.owner_user_id as owner_user_id",
176
+ "owners.email as owner_email"
177
+ ]).where("workspaces.id", "=", input.workspaceId).executeTakeFirst();
178
+ if (!row) return null;
179
+ const memberCountRow = await this.db.selectFrom("workspace_members").select((eb) => eb.fn.countAll().as("count")).where("workspace_id", "=", input.workspaceId).executeTakeFirstOrThrow();
180
+ return {
181
+ id: row.id,
182
+ name: row.name,
183
+ description: row.description ?? void 0,
184
+ createdAt: row.created_at,
185
+ deleted: row.deleted === 1,
186
+ deletedAt: row.deleted_at ?? void 0,
187
+ ownerUserId: row.owner_user_id ?? void 0,
188
+ ownerEmail: row.owner_email ?? void 0,
189
+ memberCount: Number(memberCountRow.count ?? 0)
190
+ };
191
+ }
192
+ async createWorkspace(input) {
193
+ const owner = await this.db.selectFrom("users").select("id").where("id", "=", input.ownerUserId).executeTakeFirst();
194
+ if (!owner) {
195
+ throw new Error("Owner user not found");
196
+ }
197
+ const workspaceId = randomUUID();
198
+ const now = nowEpoch();
199
+ await this.db.transaction().execute(async (tx) => {
200
+ await tx.insertInto("workspaces").values({
201
+ id: workspaceId,
202
+ name: input.name,
203
+ description: input.description ?? null,
204
+ owner_user_id: input.ownerUserId,
205
+ created_at: now,
206
+ deleted: 0,
207
+ deleted_at: null
208
+ }).execute();
209
+ await tx.insertInto("workspace_members").values({
210
+ id: randomUUID(),
211
+ workspace_id: workspaceId,
212
+ user_id: input.ownerUserId,
213
+ role: "owner",
214
+ created_at: now
215
+ }).execute();
216
+ });
217
+ return { workspaceId };
218
+ }
219
+ async softDeleteWorkspace(input) {
220
+ await this.db.updateTable("workspaces").set({
221
+ deleted: 1,
222
+ deleted_at: input.deletedAt
223
+ }).where("id", "=", input.workspaceId).execute();
224
+ }
225
+ async restoreWorkspace(input) {
226
+ await this.db.updateTable("workspaces").set({
227
+ deleted: 0,
228
+ deleted_at: null
229
+ }).where("id", "=", input.workspaceId).execute();
230
+ }
231
+ async searchUsers(input) {
232
+ const query = input.query.trim();
233
+ const limit = Math.max(1, Math.min(input.limit ?? 20, 100));
234
+ if (!query) return [];
235
+ const pattern = `%${query}%`;
236
+ const rows = await this.db.selectFrom("users").select(["id", "email", "display_name"]).where(
237
+ (eb) => eb.or([
238
+ eb("email", "like", pattern),
239
+ eb("display_name", "like", pattern),
240
+ eb("id", "like", pattern)
241
+ ])
242
+ ).limit(limit).execute();
243
+ return rows.map((row) => ({
244
+ userId: row.id,
245
+ email: row.email ?? void 0,
246
+ displayName: row.display_name ?? void 0
247
+ }));
248
+ }
249
+ }
250
+ class SqliteWorkspaceSettingsStore {
251
+ get db() {
252
+ return getSqliteDb();
253
+ }
254
+ async get(workspaceId, key) {
255
+ const row = await this.db.selectFrom("admin_workspace_settings").select("value").where("workspace_id", "=", workspaceId).where("key", "=", key).executeTakeFirst();
256
+ return row?.value ?? null;
257
+ }
258
+ async set(workspaceId, key, value) {
259
+ await this.db.insertInto("admin_workspace_settings").values({
260
+ id: randomUUID(),
261
+ workspace_id: workspaceId,
262
+ key,
263
+ value,
264
+ updated_at: nowEpoch()
265
+ }).onConflict(
266
+ (oc) => oc.columns(["workspace_id", "key"]).doUpdateSet({
267
+ value,
268
+ updated_at: nowEpoch()
269
+ })
270
+ ).execute();
271
+ }
272
+ }
273
+ class SqliteAdminUserStore {
274
+ get db() {
275
+ return getSqliteDb();
276
+ }
277
+ async listAdmins() {
278
+ const rows = await this.db.selectFrom("admin_users").innerJoin("users", "users.id", "admin_users.user_id").select([
279
+ "admin_users.user_id as user_id",
280
+ "users.email as email",
281
+ "users.display_name as display_name",
282
+ "admin_users.created_at as created_at"
283
+ ]).orderBy("admin_users.created_at", "desc").execute();
284
+ return rows.map((row) => ({
285
+ userId: row.user_id,
286
+ email: row.email ?? void 0,
287
+ displayName: row.display_name ?? void 0,
288
+ createdAt: row.created_at
289
+ }));
290
+ }
291
+ async grantAdmin(input) {
292
+ const user = await this.db.selectFrom("users").select("id").where("id", "=", input.userId).executeTakeFirst();
293
+ if (!user) {
294
+ throw new Error("User not found");
295
+ }
296
+ await this.db.insertInto("admin_users").values({
297
+ user_id: input.userId,
298
+ created_at: nowEpoch(),
299
+ created_by_user_id: input.createdByUserId ?? null
300
+ }).onConflict((oc) => oc.column("user_id").doNothing()).execute();
301
+ }
302
+ async revokeAdmin(input) {
303
+ await this.db.deleteFrom("admin_users").where("user_id", "=", input.userId).execute();
304
+ }
305
+ async isAdmin(input) {
306
+ const row = await this.db.selectFrom("admin_users").select("user_id").where("user_id", "=", input.userId).executeTakeFirst();
307
+ return Boolean(row);
308
+ }
309
+ async searchUsers(input) {
310
+ const query = input.query.trim();
311
+ const limit = Math.max(1, Math.min(input.limit ?? 20, 100));
312
+ if (!query) return [];
313
+ const pattern = `%${query}%`;
314
+ const rows = await this.db.selectFrom("users").select(["id", "email", "display_name"]).where(
315
+ (eb) => eb.or([
316
+ eb("email", "like", pattern),
317
+ eb("display_name", "like", pattern),
318
+ eb("id", "like", pattern)
319
+ ])
320
+ ).limit(limit).execute();
321
+ return rows.map((row) => ({
322
+ userId: row.id,
323
+ email: row.email ?? void 0,
324
+ displayName: row.display_name ?? void 0
325
+ }));
326
+ }
327
+ }
328
+ export function createSqliteWorkspaceAccessStore() {
329
+ return new SqliteWorkspaceAccessStore();
330
+ }
331
+ export function createSqliteWorkspaceSettingsStore() {
332
+ return new SqliteWorkspaceSettingsStore();
333
+ }
334
+ export function createSqliteAdminUserStore() {
335
+ return new SqliteAdminUserStore();
336
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * SQLite implementation of AuthWorkspaceStore.
3
+ *
4
+ * Maps provider identity -> internal user and manages workspace CRUD.
5
+ * Uses Kysely for all queries. No Convex or Clerk SDK imports.
6
+ */
7
+ import type { AuthWorkspaceStore } from '~~/server/auth/store/types';
8
+ import type { WorkspaceRole } from '~~/app/core/hooks/hook-types';
9
+ export declare class SqliteAuthWorkspaceStore implements AuthWorkspaceStore {
10
+ private get db();
11
+ getOrCreateUser(input: {
12
+ provider: string;
13
+ providerUserId: string;
14
+ email?: string;
15
+ displayName?: string;
16
+ }): Promise<{
17
+ userId: string;
18
+ }>;
19
+ getUser(input: {
20
+ provider: string;
21
+ providerUserId: string;
22
+ }): Promise<{
23
+ userId: string;
24
+ email?: string;
25
+ displayName?: string;
26
+ } | null>;
27
+ getOrCreateDefaultWorkspace(userId: string): Promise<{
28
+ workspaceId: string;
29
+ workspaceName: string;
30
+ }>;
31
+ getWorkspaceRole(input: {
32
+ userId: string;
33
+ workspaceId: string;
34
+ }): Promise<WorkspaceRole | null>;
35
+ listUserWorkspaces(userId: string): Promise<Array<{
36
+ id: string;
37
+ name: string;
38
+ description?: string | null;
39
+ role: WorkspaceRole;
40
+ createdAt?: number;
41
+ isActive?: boolean;
42
+ }>>;
43
+ createWorkspace(input: {
44
+ userId: string;
45
+ name: string;
46
+ description?: string | null;
47
+ }): Promise<{
48
+ workspaceId: string;
49
+ }>;
50
+ updateWorkspace(input: {
51
+ userId: string;
52
+ workspaceId: string;
53
+ name: string;
54
+ description?: string | null;
55
+ }): Promise<void>;
56
+ removeWorkspace(input: {
57
+ userId: string;
58
+ workspaceId: string;
59
+ }): Promise<void>;
60
+ setActiveWorkspace(input: {
61
+ userId: string;
62
+ workspaceId: string;
63
+ }): Promise<void>;
64
+ createInvite(input: {
65
+ workspaceId: string;
66
+ email: string;
67
+ role: WorkspaceRole;
68
+ invitedByUserId: string;
69
+ expiresAt: number;
70
+ tokenHash: string;
71
+ }): Promise<{
72
+ inviteId: string;
73
+ }>;
74
+ listInvites(input: {
75
+ workspaceId: string;
76
+ status?: 'pending' | 'accepted' | 'revoked' | 'expired';
77
+ limit?: number;
78
+ }): Promise<{
79
+ id: string;
80
+ workspaceId: string;
81
+ email: string;
82
+ role: WorkspaceRole;
83
+ status: "pending" | "accepted" | "revoked" | "expired";
84
+ invitedByUserId: string;
85
+ expiresAt: number;
86
+ tokenHash: string;
87
+ acceptedAt: number | null;
88
+ acceptedUserId: string | null;
89
+ revokedAt: number | null;
90
+ createdAt: number;
91
+ updatedAt: number;
92
+ }[]>;
93
+ revokeInvite(input: {
94
+ workspaceId: string;
95
+ inviteId: string;
96
+ revokedByUserId: string;
97
+ }): Promise<void>;
98
+ consumeInvite(input: {
99
+ workspaceId: string;
100
+ email: string;
101
+ tokenHash: string;
102
+ acceptedUserId: string;
103
+ }): Promise<{
104
+ ok: true;
105
+ role: WorkspaceRole;
106
+ } | {
107
+ ok: false;
108
+ reason: 'not_found' | 'expired' | 'revoked' | 'already_used' | 'token_mismatch';
109
+ }>;
110
+ }
111
+ export declare function createSqliteAuthWorkspaceStore(): AuthWorkspaceStore;