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.
- package/README.md +109 -0
- package/dist/module.d.mts +5 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +11 -0
- package/dist/runtime/server/admin/adapters/sync-sqlite.d.ts +2 -0
- package/dist/runtime/server/admin/adapters/sync-sqlite.js +72 -0
- package/dist/runtime/server/admin/stores/sqlite-store.d.ts +4 -0
- package/dist/runtime/server/admin/stores/sqlite-store.js +336 -0
- package/dist/runtime/server/auth/sqlite-auth-workspace-store.d.ts +111 -0
- package/dist/runtime/server/auth/sqlite-auth-workspace-store.js +349 -0
- package/dist/runtime/server/db/kysely.d.ts +32 -0
- package/dist/runtime/server/db/kysely.js +62 -0
- package/dist/runtime/server/db/migrate.d.ts +10 -0
- package/dist/runtime/server/db/migrate.js +38 -0
- package/dist/runtime/server/db/migrations/001_init.d.ts +6 -0
- package/dist/runtime/server/db/migrations/001_init.js +31 -0
- package/dist/runtime/server/db/migrations/002_sync_tables.d.ts +6 -0
- package/dist/runtime/server/db/migrations/002_sync_tables.js +55 -0
- package/dist/runtime/server/db/migrations/003_sync_hardening.d.ts +9 -0
- package/dist/runtime/server/db/migrations/003_sync_hardening.js +67 -0
- package/dist/runtime/server/db/migrations/004_auth_invites.d.ts +3 -0
- package/dist/runtime/server/db/migrations/004_auth_invites.js +18 -0
- package/dist/runtime/server/db/migrations/005_admin_stores.d.ts +7 -0
- package/dist/runtime/server/db/migrations/005_admin_stores.js +12 -0
- package/dist/runtime/server/db/schema.d.ts +138 -0
- package/dist/runtime/server/db/schema.js +10 -0
- package/dist/runtime/server/plugins/register.d.ts +2 -0
- package/dist/runtime/server/plugins/register.js +48 -0
- package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.d.ts +36 -0
- package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.js +366 -0
- package/dist/types.d.mts +7 -0
- 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
|
package/dist/module.json
ADDED
package/dist/module.mjs
ADDED
|
@@ -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,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;
|