@selvajs/supabase-provider 0.14.4-beta.0 → 0.15.0-beta.2

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 (158) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -0
  3. package/dist/auth/SupabaseAuthProvider.d.ts.map +1 -1
  4. package/dist/auth/SupabaseAuthProvider.js +7 -14
  5. package/dist/auth/SupabaseAuthProvider.js.map +1 -1
  6. package/dist/data/SupabaseComputeServerStore.d.ts +14 -3
  7. package/dist/data/SupabaseComputeServerStore.d.ts.map +1 -1
  8. package/dist/data/SupabaseComputeServerStore.js +67 -16
  9. package/dist/data/SupabaseComputeServerStore.js.map +1 -1
  10. package/dist/data/SupabaseDataProvider.d.ts +36 -8
  11. package/dist/data/SupabaseDataProvider.d.ts.map +1 -1
  12. package/dist/data/SupabaseDataProvider.js +119 -15
  13. package/dist/data/SupabaseDataProvider.js.map +1 -1
  14. package/dist/data/SupabaseDefinitionStore.d.ts +1 -0
  15. package/dist/data/SupabaseDefinitionStore.d.ts.map +1 -1
  16. package/dist/data/SupabaseDefinitionStore.js +41 -4
  17. package/dist/data/SupabaseDefinitionStore.js.map +1 -1
  18. package/dist/data/SupabaseEventSink.d.ts +5 -2
  19. package/dist/data/SupabaseEventSink.d.ts.map +1 -1
  20. package/dist/data/SupabaseEventSink.js +20 -3
  21. package/dist/data/SupabaseEventSink.js.map +1 -1
  22. package/dist/data/SupabaseSolveMetricSink.d.ts +49 -4
  23. package/dist/data/SupabaseSolveMetricSink.d.ts.map +1 -1
  24. package/dist/data/SupabaseSolveMetricSink.js +109 -19
  25. package/dist/data/SupabaseSolveMetricSink.js.map +1 -1
  26. package/dist/data/index.d.ts +1 -0
  27. package/dist/data/index.d.ts.map +1 -1
  28. package/dist/data/index.js +1 -0
  29. package/dist/data/index.js.map +1 -1
  30. package/dist/data/migrationHead.d.ts +14 -0
  31. package/dist/data/migrationHead.d.ts.map +1 -0
  32. package/dist/data/migrationHead.js +14 -0
  33. package/dist/data/migrationHead.js.map +1 -0
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/permissions/SupabasePlatformPermissionStore.d.ts +5 -3
  39. package/dist/permissions/SupabasePlatformPermissionStore.d.ts.map +1 -1
  40. package/dist/permissions/SupabasePlatformPermissionStore.js +17 -30
  41. package/dist/permissions/SupabasePlatformPermissionStore.js.map +1 -1
  42. package/dist/storage/SupabaseStorageProvider.d.ts.map +1 -1
  43. package/dist/storage/SupabaseStorageProvider.js +3 -0
  44. package/dist/storage/SupabaseStorageProvider.js.map +1 -1
  45. package/package.json +23 -14
  46. package/supabase/migrations/20260712120000_user_profiles_disabled_and_gin.sql +95 -0
  47. package/supabase/migrations/20260712123000_selva_migration_head_fn.sql +38 -0
  48. package/supabase/migrations/20260713120000_selva_next_version_number.sql +59 -0
  49. package/supabase/migrations/20260713140000_selva_solve_cache_limit.sql +15 -0
  50. package/supabase/migrations/20260716120000_definition_status_and_audit_version.sql +36 -0
  51. package/supabase/migrations/20260716130000_erase_user_audit_email.sql +36 -0
  52. package/supabase/migrations/20260717120000_compute_server_has_api_key.sql +20 -0
  53. package/dist/auth/__tests__/conformance.test.d.ts +0 -2
  54. package/dist/auth/__tests__/conformance.test.d.ts.map +0 -1
  55. package/dist/auth/__tests__/conformance.test.js +0 -76
  56. package/dist/auth/__tests__/conformance.test.js.map +0 -1
  57. package/dist/auth/__tests__/token-verification.test.d.ts +0 -13
  58. package/dist/auth/__tests__/token-verification.test.d.ts.map +0 -1
  59. package/dist/auth/__tests__/token-verification.test.js +0 -135
  60. package/dist/auth/__tests__/token-verification.test.js.map +0 -1
  61. package/dist/data/__tests__/compute-server-conformance.test.d.ts +0 -2
  62. package/dist/data/__tests__/compute-server-conformance.test.d.ts.map +0 -1
  63. package/dist/data/__tests__/compute-server-conformance.test.js +0 -45
  64. package/dist/data/__tests__/compute-server-conformance.test.js.map +0 -1
  65. package/dist/data/__tests__/compute-server-encryption.test.d.ts +0 -12
  66. package/dist/data/__tests__/compute-server-encryption.test.d.ts.map +0 -1
  67. package/dist/data/__tests__/compute-server-encryption.test.js +0 -143
  68. package/dist/data/__tests__/compute-server-encryption.test.js.map +0 -1
  69. package/dist/data/__tests__/definition-conformance.test.d.ts +0 -2
  70. package/dist/data/__tests__/definition-conformance.test.d.ts.map +0 -1
  71. package/dist/data/__tests__/definition-conformance.test.js +0 -85
  72. package/dist/data/__tests__/definition-conformance.test.js.map +0 -1
  73. package/dist/data/__tests__/event-sink-conformance.test.d.ts +0 -2
  74. package/dist/data/__tests__/event-sink-conformance.test.d.ts.map +0 -1
  75. package/dist/data/__tests__/event-sink-conformance.test.js +0 -27
  76. package/dist/data/__tests__/event-sink-conformance.test.js.map +0 -1
  77. package/dist/data/__tests__/invite-conformance.test.d.ts +0 -2
  78. package/dist/data/__tests__/invite-conformance.test.d.ts.map +0 -1
  79. package/dist/data/__tests__/invite-conformance.test.js +0 -45
  80. package/dist/data/__tests__/invite-conformance.test.js.map +0 -1
  81. package/dist/data/__tests__/org-conformance.test.d.ts +0 -2
  82. package/dist/data/__tests__/org-conformance.test.d.ts.map +0 -1
  83. package/dist/data/__tests__/org-conformance.test.js +0 -29
  84. package/dist/data/__tests__/org-conformance.test.js.map +0 -1
  85. package/dist/data/__tests__/project-conformance.test.d.ts +0 -2
  86. package/dist/data/__tests__/project-conformance.test.d.ts.map +0 -1
  87. package/dist/data/__tests__/project-conformance.test.js +0 -57
  88. package/dist/data/__tests__/project-conformance.test.js.map +0 -1
  89. package/dist/data/__tests__/share-link-concurrency.test.d.ts +0 -22
  90. package/dist/data/__tests__/share-link-concurrency.test.d.ts.map +0 -1
  91. package/dist/data/__tests__/share-link-concurrency.test.js +0 -115
  92. package/dist/data/__tests__/share-link-concurrency.test.js.map +0 -1
  93. package/dist/data/__tests__/share-link-conformance.test.d.ts +0 -2
  94. package/dist/data/__tests__/share-link-conformance.test.d.ts.map +0 -1
  95. package/dist/data/__tests__/share-link-conformance.test.js +0 -88
  96. package/dist/data/__tests__/share-link-conformance.test.js.map +0 -1
  97. package/dist/data/__tests__/solve-metric-sink-conformance.test.d.ts +0 -2
  98. package/dist/data/__tests__/solve-metric-sink-conformance.test.d.ts.map +0 -1
  99. package/dist/data/__tests__/solve-metric-sink-conformance.test.js +0 -52
  100. package/dist/data/__tests__/solve-metric-sink-conformance.test.js.map +0 -1
  101. package/dist/data/__tests__/test-helpers.d.ts +0 -52
  102. package/dist/data/__tests__/test-helpers.d.ts.map +0 -1
  103. package/dist/data/__tests__/test-helpers.js +0 -177
  104. package/dist/data/__tests__/test-helpers.js.map +0 -1
  105. package/dist/permissions/__tests__/conformance.test.d.ts +0 -2
  106. package/dist/permissions/__tests__/conformance.test.d.ts.map +0 -1
  107. package/dist/permissions/__tests__/conformance.test.js +0 -27
  108. package/dist/permissions/__tests__/conformance.test.js.map +0 -1
  109. package/dist/storage/__tests__/conformance.test.d.ts +0 -2
  110. package/dist/storage/__tests__/conformance.test.d.ts.map +0 -1
  111. package/dist/storage/__tests__/conformance.test.js +0 -149
  112. package/dist/storage/__tests__/conformance.test.js.map +0 -1
  113. package/dist/userProfile/__tests__/conformance.test.d.ts +0 -2
  114. package/dist/userProfile/__tests__/conformance.test.d.ts.map +0 -1
  115. package/dist/userProfile/__tests__/conformance.test.js +0 -38
  116. package/dist/userProfile/__tests__/conformance.test.js.map +0 -1
  117. package/src/auth/SupabaseAuthProvider.ts +0 -600
  118. package/src/auth/__tests__/conformance.test.ts +0 -78
  119. package/src/auth/__tests__/token-verification.test.ts +0 -158
  120. package/src/auth/index.ts +0 -2
  121. package/src/cli/sync-migrations.ts +0 -133
  122. package/src/data/SupabaseAuditQuery.ts +0 -103
  123. package/src/data/SupabaseComputeServerStore.ts +0 -382
  124. package/src/data/SupabaseDataProvider.ts +0 -153
  125. package/src/data/SupabaseDefinitionStore.ts +0 -527
  126. package/src/data/SupabaseEventSink.ts +0 -30
  127. package/src/data/SupabaseInviteStore.ts +0 -171
  128. package/src/data/SupabaseOrgStore.ts +0 -508
  129. package/src/data/SupabaseProjectStore.ts +0 -431
  130. package/src/data/SupabaseShareLinkStore.ts +0 -176
  131. package/src/data/SupabaseSolveMetricSink.ts +0 -38
  132. package/src/data/__tests__/compute-server-conformance.test.ts +0 -46
  133. package/src/data/__tests__/compute-server-encryption.test.ts +0 -173
  134. package/src/data/__tests__/definition-conformance.test.ts +0 -89
  135. package/src/data/__tests__/event-sink-conformance.test.ts +0 -32
  136. package/src/data/__tests__/invite-conformance.test.ts +0 -46
  137. package/src/data/__tests__/org-conformance.test.ts +0 -30
  138. package/src/data/__tests__/project-conformance.test.ts +0 -57
  139. package/src/data/__tests__/share-link-concurrency.test.ts +0 -124
  140. package/src/data/__tests__/share-link-conformance.test.ts +0 -87
  141. package/src/data/__tests__/solve-metric-sink-conformance.test.ts +0 -59
  142. package/src/data/__tests__/test-helpers.ts +0 -188
  143. package/src/data/client.ts +0 -173
  144. package/src/data/errors.ts +0 -45
  145. package/src/data/index.ts +0 -29
  146. package/src/data/pagination.ts +0 -68
  147. package/src/data/query.ts +0 -22
  148. package/src/data/rowStamp.ts +0 -48
  149. package/src/index.ts +0 -42
  150. package/src/permissions/SupabasePlatformPermissionStore.ts +0 -161
  151. package/src/permissions/__tests__/conformance.test.ts +0 -28
  152. package/src/permissions/index.ts +0 -1
  153. package/src/storage/SupabaseStorageProvider.ts +0 -182
  154. package/src/storage/__tests__/conformance.test.ts +0 -152
  155. package/src/storage/index.ts +0 -2
  156. package/src/userProfile/SupabaseUserProfileProvider.ts +0 -177
  157. package/src/userProfile/__tests__/conformance.test.ts +0 -39
  158. package/src/userProfile/index.ts +0 -1
@@ -1,59 +0,0 @@
1
- import { describe, beforeEach, it } from 'vitest';
2
- import { runSolveMetricSinkConformance, type RecordedSolveMetric } from '@selvajs/platform/testing';
3
- import { SupabaseSolveMetricSink } from '../SupabaseSolveMetricSink.js';
4
- import { buildClientBundle } from '../client.js';
5
- import { readEnv, resetAllData } from './test-helpers.js';
6
-
7
- const envCtx = readEnv();
8
-
9
- if (!envCtx) {
10
- describe.skip('SupabaseSolveMetricSink (skipped: no live stack)', () => {
11
- it('populate packages/providers/supabase/.env.test with Supabase creds to run these tests', () => {});
12
- });
13
- } else {
14
- const ctx = envCtx;
15
- describe('SupabaseSolveMetricSink', () => {
16
- beforeEach(async () => {
17
- await resetAllData(ctx);
18
- });
19
-
20
- runSolveMetricSinkConformance({
21
- name: 'SupabaseSolveMetricSink',
22
- createSink: () => new SupabaseSolveMetricSink(ctx.bundle),
23
- readRows: async () => {
24
- const { data, error } = await ctx.adminClient
25
- .from('solve_metrics')
26
- .select(
27
- 'actor_id, definition_url, definition_id, version_id, channel, org_id, duration_ms, ok, failure_kind, error_count, warning_count'
28
- )
29
- .order('occurred_at', { ascending: true });
30
- if (error) throw new Error(`solve_metrics read failed: ${error.message}`);
31
- return (data ?? []).map(
32
- (r): RecordedSolveMetric => ({
33
- actorId: r.actor_id,
34
- definitionUrl: r.definition_url,
35
- definitionId: r.definition_id,
36
- versionId: r.version_id,
37
- channel: r.channel,
38
- orgId: r.org_id,
39
- durationMs: r.duration_ms,
40
- ok: r.ok,
41
- failureKind: r.failure_kind,
42
- errorCount: r.error_count,
43
- warningCount: r.warning_count
44
- })
45
- );
46
- },
47
- // Point the sink at a bundle whose service key is bogus, so the insert
48
- // fails. record() must still resolve (contract: never throw).
49
- createFailingSink: () =>
50
- new SupabaseSolveMetricSink(
51
- buildClientBundle({
52
- supabaseUrl: ctx.url,
53
- anonKey: ctx.anonKey,
54
- serviceRoleKey: 'invalid-service-role-key'
55
- })
56
- )
57
- });
58
- });
59
- }
@@ -1,188 +0,0 @@
1
- /**
2
- * Shared test helpers for Supabase data conformance tests.
3
- *
4
- * Every suite starts with a fully wiped DB so tests are isolated. We use the
5
- * service-role client for both — seeding users in `auth.users` and wiping
6
- * every relevant table.
7
- */
8
-
9
- import { createClient } from '@supabase/supabase-js';
10
- import { buildClientBundle, type ClientBundle, type SelvaSchemaClient } from '../client.js';
11
-
12
- export interface TestContext {
13
- url: string;
14
- anonKey: string;
15
- serviceRoleKey: string;
16
- adminClient: SelvaSchemaClient;
17
- bundle: ClientBundle;
18
- }
19
-
20
- export function readEnv(): TestContext | null {
21
- const url = process.env.SUPABASE_URL;
22
- const anonKey = process.env.SUPABASE_ANON_KEY;
23
- const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
24
- if (!url || !anonKey || !serviceRoleKey) return null;
25
- // `.env.test` is checked in with local-Supabase defaults so contributors can
26
- // `npx supabase start` and run the suite. But `createClient` transitively
27
- // loads `@supabase/realtime-js`, which on Node < 22 throws at construction
28
- // without a native WebSocket. Swallow that so the suite cleanly skips
29
- // instead of crashing test collection.
30
- let adminClient: SelvaSchemaClient;
31
- try {
32
- adminClient = createClient(url, serviceRoleKey, {
33
- db: { schema: 'selva' },
34
- auth: { persistSession: false, autoRefreshToken: false }
35
- });
36
- } catch (err) {
37
- const msg = err instanceof Error ? err.message : String(err);
38
- if (msg.includes('WebSocket')) return null;
39
- throw err;
40
- }
41
- const bundle = buildClientBundle({ supabaseUrl: url, anonKey, serviceRoleKey });
42
- return { url, anonKey, serviceRoleKey, adminClient, bundle };
43
- }
44
-
45
- /**
46
- * Reset every data table this adapter owns. Leaves Supabase-managed tables
47
- * (`auth.users`, etc.) alone except via `deleteAllAuthUsers` below.
48
- *
49
- * Order-sensitive because some FKs don't cascade: we go bottom up from
50
- * dependents to parents.
51
- */
52
- export async function resetAllData(ctx: TestContext): Promise<void> {
53
- await truncate(ctx, 'solve_metrics');
54
- await truncate(ctx, 'share_links');
55
- await truncate(ctx, 'invites');
56
- // Versions FK to definitions ON DELETE CASCADE, but `definitions` can't
57
- // be wiped while live/draft pointers reference versions (ON DELETE
58
- // RESTRICT, spec §6). Null the pointers, drop versions, then drop defs.
59
- await ctx.adminClient
60
- .from('definitions')
61
- .update({ live_version_id: null, draft_version_id: null })
62
- .not('guid', 'is', null);
63
- await truncate(ctx, 'definition_versions');
64
- await truncate(ctx, 'definitions');
65
- await truncate(ctx, 'project_members');
66
- // compute defaults have a platform_default sibling — reset all three.
67
- await truncate(ctx, 'compute_server_org_defaults');
68
- await truncate(ctx, 'compute_server_shares');
69
- await resetComputePlatformDefault(ctx);
70
- await truncate(ctx, 'compute_servers');
71
- await truncate(ctx, 'org_members');
72
- await truncate(ctx, 'orgs');
73
- await truncate(ctx, 'user_profiles');
74
- await deleteAllAuthUsers(ctx);
75
- }
76
-
77
- async function resetComputePlatformDefault(ctx: TestContext): Promise<void> {
78
- // Singleton row — clear `default_server_id` rather than deleting.
79
- const { error } = await ctx.adminClient
80
- .from('compute_server_platform_default')
81
- .update({ default_server_id: null })
82
- .eq('singleton', true);
83
- if (error) throw error;
84
- }
85
-
86
- async function truncate(ctx: TestContext, table: string): Promise<void> {
87
- const { error } = await ctx.adminClient
88
- .from(table)
89
- .delete()
90
- .neq(columnForDeleteAll(table), '00000000-0000-0000-0000-000000000000');
91
- if (error) throw error;
92
- }
93
-
94
- function columnForDeleteAll(table: string): string {
95
- if (table === 'org_members') return 'user_id';
96
- if (table === 'project_members') return 'user_id';
97
- if (table === 'user_profiles') return 'user_id';
98
- if (table === 'definitions') return 'guid';
99
- if (table === 'definition_versions') return 'id';
100
- if (table === 'compute_server_org_defaults') return 'org_id';
101
- if (table === 'compute_server_shares') return 'server_id';
102
- return 'id';
103
- }
104
-
105
- async function deleteAllAuthUsers(ctx: TestContext): Promise<void> {
106
- const { data, error } = await ctx.adminClient.auth.admin.listUsers({ perPage: 1000 });
107
- if (error) throw error;
108
- for (const user of data.users) {
109
- const { error: delError } = await ctx.adminClient.auth.admin.deleteUser(user.id);
110
- if (delError) throw delError;
111
- }
112
- }
113
-
114
- /**
115
- * Seed an `auth.users` row, mark the user as `instance_admin`, sign in, and
116
- * return the real id + access token. `auth.admin.createUser` doesn't accept a
117
- * caller-supplied id — Supabase generates one. The conformance suite calls us
118
- * with a *suggested* id (for adapters that honor it); we ignore it and return
119
- * GoTrue's real id.
120
- *
121
- * The instance_admin grant is what makes RLS open up for the conformance
122
- * tests: every policy in the schema accepts `is_instance_admin()` as a
123
- * universal pass, mirroring the `instance_admin` default in `makeCtx`.
124
- *
125
- * The session token is what makes RLS *see* the user at all. Without it, the
126
- * Supabase client falls back to the anon role and every write fails.
127
- */
128
- export async function seedUser(
129
- ctx: TestContext,
130
- suggestedId: string
131
- ): Promise<{ userId: string; sessionToken: string }> {
132
- return seedUserCore(ctx, suggestedId, { promoteToInstanceAdmin: true });
133
- }
134
-
135
- /**
136
- * Variant of `seedUser` that leaves `platform_permissions` empty. Use this
137
- * when a test specifically asserts on a fresh user's permission state (e.g.
138
- * the platform-permission store conformance suite).
139
- */
140
- export async function seedPlainUser(
141
- ctx: TestContext,
142
- suggestedId: string
143
- ): Promise<{ userId: string; sessionToken: string }> {
144
- return seedUserCore(ctx, suggestedId, { promoteToInstanceAdmin: false });
145
- }
146
-
147
- async function seedUserCore(
148
- ctx: TestContext,
149
- _suggestedId: string,
150
- opts: { promoteToInstanceAdmin: boolean }
151
- ): Promise<{ userId: string; sessionToken: string }> {
152
- // Unique email per seed so repeated calls don't collide. GoTrue requires
153
- // an email even for password-less users.
154
- const email = `conformance-${crypto.randomUUID()}@conformance.test`;
155
- const password = 'conformance-test-password-1234';
156
- const { data, error } = await ctx.adminClient.auth.admin.createUser({
157
- email,
158
- password,
159
- email_confirm: true
160
- });
161
- if (error) throw error;
162
- const userId = data.user.id;
163
-
164
- if (opts.promoteToInstanceAdmin) {
165
- // Trigger auto-created the user_profiles row; promote to instance_admin
166
- // so every RLS policy treats this user as fully authorized. Mirrors the
167
- // `instance_admin` default in `makeCtx`.
168
- const { error: promoteError } = await ctx.adminClient
169
- .from('user_profiles')
170
- .update({ platform_permissions: ['instance_admin'] })
171
- .eq('user_id', userId);
172
- if (promoteError) throw promoteError;
173
- }
174
-
175
- // Sign in to get an access token. The conformance suite stuffs this into
176
- // `adapterContext.sessionToken` and our client.ts uses it to scope the
177
- // request to this user instead of falling back to anon.
178
- const signInClient = createClient(ctx.url, ctx.anonKey, {
179
- auth: { persistSession: false, autoRefreshToken: false }
180
- });
181
- const { data: session, error: signInError } = await signInClient.auth.signInWithPassword({
182
- email,
183
- password
184
- });
185
- if (signInError) throw signInError;
186
- if (!session.session) throw new Error('seedUser: signInWithPassword returned no session');
187
- return { userId, sessionToken: session.session.access_token };
188
- }
@@ -1,173 +0,0 @@
1
- import { createClient, type SupabaseClient } from '@supabase/supabase-js';
2
- import type { RequestContext } from '@selvajs/platform';
3
-
4
- /**
5
- * All engine tables live in the `selva` schema (not `public`) so a consuming
6
- * app keeps `public` for its own tables. Every data client targets `selva` via
7
- * `db: { schema: 'selva' }`, which the supabase-js type system surfaces as the
8
- * client's schema generic — hence this alias rather than the bare default
9
- * `SupabaseClient` (which is pinned to `'public'`).
10
- */
11
- export type SelvaSchemaClient = SupabaseClient<any, 'public', 'selva'>;
12
-
13
- /** Default schema every engine store targets. */
14
- export const DEFAULT_SCHEMA = 'selva' as const;
15
-
16
- /**
17
- * A client pinned to a caller-chosen schema. When the schema is `'selva'` this
18
- * is the narrow `SelvaSchemaClient`; for an app-owned schema (e.g. `'public'`)
19
- * the schema generic is opaque — the store surface (`.from` / `.rpc`) is the
20
- * same either way.
21
- */
22
- export type SchemaClient = SupabaseClient<any, string, any>;
23
-
24
- export interface ForRequestOptions {
25
- /**
26
- * Postgres schema this client targets. Defaults to `'selva'` (the engine's
27
- * tables). Pass `'public'` (or another app schema) to build stores on the
28
- * consuming app's own tables while keeping the same fail-closed RLS
29
- * dispatch as the engine stores.
30
- */
31
- schema?: string;
32
- }
33
-
34
- /**
35
- * Build a Supabase client for a given `RequestContext`.
36
- *
37
- * Three modes — fail-closed:
38
- * - **Service-role** ONLY when `ctx.system === true`. Bypasses RLS. Used for
39
- * admin paths, janitors, bootstrap, and any explicitly-trusted server
40
- * flow. The system flag must never be derived from a user session.
41
- * - **User-scoped** when `ctx.adapterContext.sessionToken` is a JWT: runs
42
- * under `authenticated` role with `auth.uid()` from the token. RLS
43
- * policies enforce per-user visibility.
44
- * - **Anonymous** otherwise: runs under the `anon` role. RLS is active and
45
- * will scope reads/writes accordingly. This is the safety net — any code
46
- * that constructs a context without forwarding the session token AND
47
- * without setting `system: true` is treated as untrusted.
48
- *
49
- * Previous versions fell back to service-role when the session token was
50
- * absent — a fail-OPEN footgun: any synthetic ctx (e.g. share-token resolve)
51
- * silently bypassed RLS. The current contract is explicit: opt into
52
- * service-role with `system: true`, or you get RLS.
53
- *
54
- * Per-request WeakMap caching keeps a single client alive for the lifetime
55
- * of a RequestContext — multiple store calls in one HTTP handler share one
56
- * fetch connection without leaking auth across requests.
57
- */
58
- export interface ClientBundle {
59
- /** Long-lived service-role client, pinned to the `'selva'` schema. Bypasses RLS. */
60
- serviceClient: SelvaSchemaClient;
61
- /**
62
- * Produce (or reuse) a client scoped to this request's identity, targeting
63
- * `opts.schema` (default `'selva'`).
64
- * - `ctx.system === true` → service-role (RLS bypassed).
65
- * - `ctx.adapterContext.sessionToken` set → user-scoped (RLS active).
66
- * - neither → anon client (RLS active, no `auth.uid()`).
67
- *
68
- * The schema pin and the RLS dispatch travel together: an app building
69
- * stores on its own `'public'` tables gets the same fail-closed behavior as
70
- * engine stores, just aimed at a different schema.
71
- */
72
- forRequest(ctx: RequestContext, opts?: ForRequestOptions): SchemaClient;
73
- }
74
-
75
- export interface BuildClientOptions {
76
- supabaseUrl: string;
77
- anonKey: string;
78
- serviceRoleKey: string;
79
- }
80
-
81
- export function buildClientBundle(opts: BuildClientOptions): ClientBundle {
82
- // Long-lived service-role + anon clients, one per schema. Both are stateless
83
- // w.r.t. request identity, so a single instance is safe to share across all
84
- // requests targeting the same schema.
85
- const serviceBySchema = new Map<string, SchemaClient>();
86
- const anonBySchema = new Map<string, SchemaClient>();
87
-
88
- function serviceFor(schema: string): SchemaClient {
89
- let client = serviceBySchema.get(schema);
90
- if (!client) {
91
- client = createClient(opts.supabaseUrl, opts.serviceRoleKey, {
92
- db: { schema },
93
- auth: { persistSession: false, autoRefreshToken: false }
94
- });
95
- serviceBySchema.set(schema, client);
96
- }
97
- return client;
98
- }
99
-
100
- function anonFor(schema: string): SchemaClient {
101
- let client = anonBySchema.get(schema);
102
- if (!client) {
103
- client = createClient(opts.supabaseUrl, opts.anonKey, {
104
- db: { schema },
105
- auth: { persistSession: false, autoRefreshToken: false }
106
- });
107
- anonBySchema.set(schema, client);
108
- }
109
- return client;
110
- }
111
-
112
- // The `'selva'`-pinned service client is exposed directly on the bundle for
113
- // engine code that reaches for RLS-bypassing reads without a request context.
114
- const serviceClient = serviceFor(DEFAULT_SCHEMA) as SelvaSchemaClient;
115
-
116
- // Per-request user-scoped clients, keyed first by context then by schema so
117
- // a handler that touches both `'selva'` and an app schema reuses one client
118
- // per (request, schema) without leaking auth across requests.
119
- const perRequestCache = new WeakMap<RequestContext, Map<string, SchemaClient>>();
120
-
121
- return {
122
- serviceClient,
123
- forRequest(ctx: RequestContext, forOpts?: ForRequestOptions): SchemaClient {
124
- const schema = forOpts?.schema ?? DEFAULT_SCHEMA;
125
- if (ctx.system) return serviceFor(schema);
126
-
127
- const token = extractSessionToken(ctx.adapterContext);
128
- if (!token) return anonFor(schema);
129
-
130
- let bySchema = perRequestCache.get(ctx);
131
- if (!bySchema) {
132
- bySchema = new Map();
133
- perRequestCache.set(ctx, bySchema);
134
- }
135
- const cached = bySchema.get(schema);
136
- if (cached) return cached;
137
-
138
- const client = createClient(opts.supabaseUrl, opts.anonKey, {
139
- db: { schema },
140
- auth: { persistSession: false, autoRefreshToken: false },
141
- global: {
142
- headers: { Authorization: `Bearer ${token}` }
143
- }
144
- });
145
- bySchema.set(schema, client);
146
- return client;
147
- }
148
- };
149
- }
150
-
151
- /**
152
- * Build a `ClientBundle` straight from environment variables — the
153
- * `SUPABASE_URL` / `SUPABASE_ANON_KEY` / `SUPABASE_SERVICE_ROLE_KEY` triple,
154
- * nothing else. Deliberately does NOT require `SELVA_AT_REST_KEY`: that key
155
- * only matters for compute-server secret encryption, which a consuming app
156
- * building its own `'public'` stores never touches. `SupabaseDataProvider.fromEnv`
157
- * layers the at-rest-key requirement on top of this.
158
- */
159
- export function clientBundleFromEnv(env: Record<string, string | undefined>): ClientBundle {
160
- const supabaseUrl = env.SUPABASE_URL;
161
- const anonKey = env.SUPABASE_ANON_KEY;
162
- const serviceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY;
163
- if (!supabaseUrl) throw new Error('Missing required env var: SUPABASE_URL');
164
- if (!anonKey) throw new Error('Missing required env var: SUPABASE_ANON_KEY');
165
- if (!serviceRoleKey) throw new Error('Missing required env var: SUPABASE_SERVICE_ROLE_KEY');
166
- return buildClientBundle({ supabaseUrl, anonKey, serviceRoleKey });
167
- }
168
-
169
- function extractSessionToken(adapterContext: unknown): string | undefined {
170
- if (!adapterContext || typeof adapterContext !== 'object') return undefined;
171
- const maybe = (adapterContext as { sessionToken?: unknown }).sessionToken;
172
- return typeof maybe === 'string' && maybe.length > 0 ? maybe : undefined;
173
- }
@@ -1,45 +0,0 @@
1
- import { ProviderError } from '@selvajs/platform';
2
-
3
- /**
4
- * Canonical PostgREST/Postgres error translation shared by every Supabase
5
- * store. Replaces the six near-identical private `mapError` copies that had
6
- * drifted into two variants (a "full" one preserving `details`/`hint`, and a
7
- * slimmer message-only one) and none of which handled `PGRST116`.
8
- *
9
- * Mapping:
10
- * - `PGRST116` (single-row selector matched zero rows) → 404. PostgREST
11
- * raises this when `.single()` finds nothing; treating it as "not found"
12
- * is almost always what a store's caller wants.
13
- * - `23505` (unique violation) / `23503` (foreign-key violation) → 409.
14
- * - Anything else → a plain `Error` (500 downstream via `api-errors`), with
15
- * `details`/`hint`/`code` preserved on the error object for logs.
16
- */
17
-
18
- /** The subset of a `PostgrestError` this mapper inspects. */
19
- interface PostgrestErrorShape {
20
- code?: string;
21
- message?: string;
22
- details?: string;
23
- hint?: string;
24
- }
25
-
26
- export function mapPostgrestError(e: unknown): Error {
27
- const pg = e as PostgrestErrorShape;
28
- switch (pg?.code) {
29
- case 'PGRST116':
30
- return new ProviderError(pg.message ?? 'Not found', 404);
31
- case '23505':
32
- return new ProviderError(pg.message ?? 'Duplicate record', 409);
33
- case '23503':
34
- return new ProviderError(pg.message ?? 'Foreign key violation', 409);
35
- }
36
- if (e instanceof Error) return e;
37
- if (e && typeof e === 'object') {
38
- const obj = e as PostgrestErrorShape;
39
- const msg = obj.message ?? obj.details ?? obj.hint ?? 'Unknown Postgres error';
40
- const err = new Error(obj.code ? `[${obj.code}] ${msg}` : msg);
41
- Object.assign(err, obj);
42
- return err;
43
- }
44
- return new Error(String(e));
45
- }
package/src/data/index.ts DELETED
@@ -1,29 +0,0 @@
1
- export { SupabaseOrgStore } from './SupabaseOrgStore.js';
2
- export { SupabaseProjectStore } from './SupabaseProjectStore.js';
3
- export { SupabaseDefinitionStore } from './SupabaseDefinitionStore.js';
4
- export { SupabaseInviteStore } from './SupabaseInviteStore.js';
5
- export { SupabaseComputeServerStore } from './SupabaseComputeServerStore.js';
6
- export { SupabaseShareLinkStore } from './SupabaseShareLinkStore.js';
7
- export { SupabaseDataProvider } from './SupabaseDataProvider.js';
8
- export { SupabaseEventSink } from './SupabaseEventSink.js';
9
- export { SupabaseSolveMetricSink } from './SupabaseSolveMetricSink.js';
10
- export { SupabaseAuditQuery } from './SupabaseAuditQuery.js';
11
- export { buildClientBundle, clientBundleFromEnv, DEFAULT_SCHEMA } from './client.js';
12
- export type {
13
- ClientBundle,
14
- BuildClientOptions,
15
- ForRequestOptions,
16
- SchemaClient,
17
- SelvaSchemaClient
18
- } from './client.js';
19
- export { mapPostgrestError } from './errors.js';
20
- export { stampUpdate, stampSoftDelete } from './rowStamp.js';
21
- export { notDeleted } from './query.js';
22
- export {
23
- toRange,
24
- orderColumn,
25
- nextCursorFromRange,
26
- encodeCursor,
27
- decodeCursor
28
- } from './pagination.js';
29
- export type { RangeSpec } from './pagination.js';
@@ -1,68 +0,0 @@
1
- /**
2
- * Pagination helpers shared across Supabase stores.
3
- *
4
- * The platform contract specifies cursor pagination (`ListOptions.cursor`).
5
- * Because Supabase/Postgres has no built-in opaque cursor format, we encode
6
- * an offset as the cursor — simple, correct for stable sorts, and good
7
- * enough for the volumes Selva deals with (admin/definition lists are
8
- * bounded; nothing is infinite scroll over millions of rows).
9
- *
10
- * Keyset pagination would be faster at scale if it ever matters.
11
- */
12
-
13
- import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT } from '@selvajs/platform';
14
- import type { ListOptions } from '@selvajs/platform';
15
-
16
- export interface RangeSpec {
17
- from: number;
18
- to: number;
19
- limit: number;
20
- }
21
-
22
- /** Decode an opaque cursor string. Returns 0 for missing/invalid cursors. */
23
- export function decodeCursor(cursor: string | undefined): number {
24
- if (!cursor) return 0;
25
- const n = parseInt(cursor, 10);
26
- return Number.isFinite(n) && n >= 0 ? n : 0;
27
- }
28
-
29
- export function encodeCursor(offset: number): string {
30
- return String(offset);
31
- }
32
-
33
- /** Clamp limit + compute the PostgREST `range(from, to)` bounds. */
34
- export function toRange(opts: Pick<ListOptions, 'limit' | 'cursor'> | undefined): RangeSpec {
35
- const limit = Math.min(Math.max(opts?.limit ?? DEFAULT_PAGE_LIMIT, 1), MAX_PAGE_LIMIT);
36
- const from = decodeCursor(opts?.cursor);
37
- return { from, to: from + limit - 1, limit };
38
- }
39
-
40
- /**
41
- * Build the `nextCursor` from the count returned by PostgREST. Returns
42
- * undefined when there is no next page.
43
- */
44
- export function nextCursorFromRange(
45
- range: RangeSpec,
46
- returnedCount: number,
47
- totalCount: number | null | undefined
48
- ): string | undefined {
49
- const consumed = range.from + returnedCount;
50
- if (totalCount != null) {
51
- return consumed < totalCount ? encodeCursor(consumed) : undefined;
52
- }
53
- // Without total, we can only infer "there might be more" if we got a full page.
54
- return returnedCount >= range.limit ? encodeCursor(consumed) : undefined;
55
- }
56
-
57
- /** Map `ListOptions.orderBy` to a Postgres column name. */
58
- export function orderColumn(orderBy: ListOptions['orderBy'] | undefined): string {
59
- switch (orderBy) {
60
- case 'name':
61
- return 'name';
62
- case 'updatedAt':
63
- return 'updated_at';
64
- case 'createdAt':
65
- default:
66
- return 'created_at';
67
- }
68
- }
package/src/data/query.ts DELETED
@@ -1,22 +0,0 @@
1
- /**
2
- * Small query-builder helpers shared across Supabase stores.
3
- */
4
-
5
- /**
6
- * The slice of a PostgREST filter builder these helpers touch: an `.is()`
7
- * method that returns the same builder for chaining. Generic over the builder
8
- * type so the caller's concrete builder (and its row generic) flows through.
9
- */
10
- interface DeletableQuery {
11
- is(column: string, value: null): this;
12
- }
13
-
14
- /**
15
- * Apply the soft-delete filter `deleted_at IS NULL`. The `.is('deleted_at', null)`
16
- * idiom is repeated across every store that soft-deletes; this names it.
17
- *
18
- * let q = notDeleted(client.from('projects').select(COLS));
19
- */
20
- export function notDeleted<Q extends DeletableQuery>(query: Q): Q {
21
- return query.is('deleted_at', null);
22
- }
@@ -1,48 +0,0 @@
1
- import type { RequestContext } from '@selvajs/platform';
2
- import { auditSoftDelete } from '@selvajs/platform';
3
-
4
- /**
5
- * Row-level (snake_case) audit-stamp helpers for Supabase stores.
6
- *
7
- * Platform's `auditUpdate` / `auditSoftDelete` emit camelCase *domain* fields
8
- * (`updatedAt` / `updatedBy`), but Postgres rows are snake_case, so every store
9
- * hand-translated them. These helpers own that translation once. Row-column
10
- * naming is a provider concern, so they live here rather than in platform.
11
- *
12
- * Two deliberate rules, matched to how the engine's stores already write rows:
13
- * - `updated_by` is written ONLY when `ctx.userId` is set. On system contexts
14
- * it's the empty string, and the column FKs `auth.users(id)` with
15
- * `ON DELETE SET NULL` — writing `''` would violate the FK, so it's omitted
16
- * and the existing value is left untouched.
17
- * - Plain updates DON'T stamp `updated_at`: a per-table `updated_at` trigger
18
- * already sets it. Only the soft-delete stamp writes `updated_at` explicitly,
19
- * so it matches `deleted_at` (a single timestamp for the deletion event).
20
- */
21
-
22
- /**
23
- * Stamp for a plain UPDATE: `{ updated_by }` when the context carries a user,
24
- * `{}` otherwise (the DB trigger handles `updated_at`). Spread into the row:
25
- * `const row = { ...patch, ...stampUpdate(ctx) }`.
26
- */
27
- export function stampUpdate(ctx: RequestContext): { updated_by?: string } {
28
- return ctx.userId ? { updated_by: ctx.userId } : {};
29
- }
30
-
31
- /**
32
- * Stamp for a soft-delete: `{ deleted_at, updated_at, updated_by? }`. `updated_at`
33
- * is set explicitly to equal `deleted_at`; `updated_by` follows the same
34
- * omit-when-empty rule as {@link stampUpdate}. `fallbackActor` defaults to
35
- * `ctx.userId` (matching the engine stores' `auditSoftDelete(ctx, ctx.userId)`).
36
- */
37
- export function stampSoftDelete(
38
- ctx: RequestContext,
39
- fallbackActor: string | null | undefined = ctx.userId
40
- ): { deleted_at: string; updated_at: string; updated_by?: string } {
41
- const stamp = auditSoftDelete(ctx, fallbackActor);
42
- const row: { deleted_at: string; updated_at: string; updated_by?: string } = {
43
- deleted_at: stamp.deletedAt,
44
- updated_at: stamp.updatedAt
45
- };
46
- if (stamp.updatedBy) row.updated_by = stamp.updatedBy;
47
- return row;
48
- }
package/src/index.ts DELETED
@@ -1,42 +0,0 @@
1
- export { SupabaseStorageProvider } from './storage/SupabaseStorageProvider.js';
2
- export type { SupabaseStorageProviderConfig } from './storage/SupabaseStorageProvider.js';
3
-
4
- export {
5
- SupabaseOrgStore,
6
- SupabaseProjectStore,
7
- SupabaseDefinitionStore,
8
- SupabaseInviteStore,
9
- SupabaseComputeServerStore,
10
- SupabaseShareLinkStore,
11
- SupabaseDataProvider,
12
- SupabaseEventSink,
13
- SupabaseSolveMetricSink,
14
- SupabaseAuditQuery,
15
- buildClientBundle,
16
- clientBundleFromEnv,
17
- DEFAULT_SCHEMA,
18
- mapPostgrestError,
19
- stampUpdate,
20
- stampSoftDelete,
21
- notDeleted,
22
- toRange,
23
- orderColumn,
24
- nextCursorFromRange,
25
- encodeCursor,
26
- decodeCursor
27
- } from './data/index.js';
28
- export type {
29
- ClientBundle,
30
- BuildClientOptions,
31
- ForRequestOptions,
32
- SchemaClient,
33
- SelvaSchemaClient,
34
- RangeSpec
35
- } from './data/index.js';
36
-
37
- export { SupabaseUserProfileProvider } from './userProfile/index.js';
38
-
39
- export { SupabaseAuthProvider } from './auth/index.js';
40
- export type { SupabaseAuthProviderConfig } from './auth/index.js';
41
-
42
- export { SupabasePlatformPermissionStore } from './permissions/index.js';