@selvajs/supabase-provider 0.11.0
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/LICENSE +21 -0
- package/README.md +271 -0
- package/dist/auth/SupabaseAuthProvider.d.ts +81 -0
- package/dist/auth/SupabaseAuthProvider.d.ts.map +1 -0
- package/dist/auth/SupabaseAuthProvider.js +383 -0
- package/dist/auth/SupabaseAuthProvider.js.map +1 -0
- package/dist/auth/__tests__/conformance.test.d.ts +2 -0
- package/dist/auth/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/auth/__tests__/conformance.test.js +76 -0
- package/dist/auth/__tests__/conformance.test.js.map +1 -0
- package/dist/auth/index.d.ts +3 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +2 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/data/SupabaseAuditQuery.d.ts +16 -0
- package/dist/data/SupabaseAuditQuery.d.ts.map +1 -0
- package/dist/data/SupabaseAuditQuery.js +83 -0
- package/dist/data/SupabaseAuditQuery.js.map +1 -0
- package/dist/data/SupabaseComputeServerStore.d.ts +26 -0
- package/dist/data/SupabaseComputeServerStore.d.ts.map +1 -0
- package/dist/data/SupabaseComputeServerStore.js +227 -0
- package/dist/data/SupabaseComputeServerStore.js.map +1 -0
- package/dist/data/SupabaseDataProvider.d.ts +55 -0
- package/dist/data/SupabaseDataProvider.d.ts.map +1 -0
- package/dist/data/SupabaseDataProvider.js +114 -0
- package/dist/data/SupabaseDataProvider.js.map +1 -0
- package/dist/data/SupabaseDefinitionStore.d.ts +39 -0
- package/dist/data/SupabaseDefinitionStore.d.ts.map +1 -0
- package/dist/data/SupabaseDefinitionStore.js +419 -0
- package/dist/data/SupabaseDefinitionStore.js.map +1 -0
- package/dist/data/SupabaseEventSink.d.ts +17 -0
- package/dist/data/SupabaseEventSink.d.ts.map +1 -0
- package/dist/data/SupabaseEventSink.js +31 -0
- package/dist/data/SupabaseEventSink.js.map +1 -0
- package/dist/data/SupabaseInviteStore.d.ts +22 -0
- package/dist/data/SupabaseInviteStore.d.ts.map +1 -0
- package/dist/data/SupabaseInviteStore.js +159 -0
- package/dist/data/SupabaseInviteStore.js.map +1 -0
- package/dist/data/SupabaseOrgStore.d.ts +33 -0
- package/dist/data/SupabaseOrgStore.d.ts.map +1 -0
- package/dist/data/SupabaseOrgStore.js +455 -0
- package/dist/data/SupabaseOrgStore.js.map +1 -0
- package/dist/data/SupabaseProjectStore.d.ts +29 -0
- package/dist/data/SupabaseProjectStore.d.ts.map +1 -0
- package/dist/data/SupabaseProjectStore.js +328 -0
- package/dist/data/SupabaseProjectStore.js.map +1 -0
- package/dist/data/SupabaseShareLinkStore.d.ts +26 -0
- package/dist/data/SupabaseShareLinkStore.d.ts.map +1 -0
- package/dist/data/SupabaseShareLinkStore.js +150 -0
- package/dist/data/SupabaseShareLinkStore.js.map +1 -0
- package/dist/data/__tests__/compute-server-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/compute-server-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/compute-server-conformance.test.js +40 -0
- package/dist/data/__tests__/compute-server-conformance.test.js.map +1 -0
- package/dist/data/__tests__/definition-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/definition-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/definition-conformance.test.js +85 -0
- package/dist/data/__tests__/definition-conformance.test.js.map +1 -0
- package/dist/data/__tests__/event-sink-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/event-sink-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/event-sink-conformance.test.js +27 -0
- package/dist/data/__tests__/event-sink-conformance.test.js.map +1 -0
- package/dist/data/__tests__/invite-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/invite-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/invite-conformance.test.js +45 -0
- package/dist/data/__tests__/invite-conformance.test.js.map +1 -0
- package/dist/data/__tests__/org-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/org-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/org-conformance.test.js +29 -0
- package/dist/data/__tests__/org-conformance.test.js.map +1 -0
- package/dist/data/__tests__/project-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/project-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/project-conformance.test.js +57 -0
- package/dist/data/__tests__/project-conformance.test.js.map +1 -0
- package/dist/data/__tests__/share-link-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/share-link-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/share-link-conformance.test.js +88 -0
- package/dist/data/__tests__/share-link-conformance.test.js.map +1 -0
- package/dist/data/__tests__/test-helpers.d.ts +53 -0
- package/dist/data/__tests__/test-helpers.d.ts.map +1 -0
- package/dist/data/__tests__/test-helpers.js +175 -0
- package/dist/data/__tests__/test-helpers.js.map +1 -0
- package/dist/data/client.d.ts +44 -0
- package/dist/data/client.d.ts.map +1 -0
- package/dist/data/client.js +40 -0
- package/dist/data/client.js.map +1 -0
- package/dist/data/index.d.ts +12 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +11 -0
- package/dist/data/index.js.map +1 -0
- package/dist/data/pagination.d.ts +30 -0
- package/dist/data/pagination.d.ts.map +1 -0
- package/dist/data/pagination.js +53 -0
- package/dist/data/pagination.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/permissions/SupabasePlatformPermissionStore.d.ts +25 -0
- package/dist/permissions/SupabasePlatformPermissionStore.d.ts.map +1 -0
- package/dist/permissions/SupabasePlatformPermissionStore.js +153 -0
- package/dist/permissions/SupabasePlatformPermissionStore.js.map +1 -0
- package/dist/permissions/__tests__/conformance.test.d.ts +2 -0
- package/dist/permissions/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/permissions/__tests__/conformance.test.js +27 -0
- package/dist/permissions/__tests__/conformance.test.js.map +1 -0
- package/dist/permissions/index.d.ts +2 -0
- package/dist/permissions/index.d.ts.map +1 -0
- package/dist/permissions/index.js +2 -0
- package/dist/permissions/index.js.map +1 -0
- package/dist/storage/SupabaseStorageProvider.d.ts +67 -0
- package/dist/storage/SupabaseStorageProvider.d.ts.map +1 -0
- package/dist/storage/SupabaseStorageProvider.js +136 -0
- package/dist/storage/SupabaseStorageProvider.js.map +1 -0
- package/dist/storage/__tests__/conformance.test.d.ts +2 -0
- package/dist/storage/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/storage/__tests__/conformance.test.js +149 -0
- package/dist/storage/__tests__/conformance.test.js.map +1 -0
- package/dist/storage/index.d.ts +3 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +2 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/userProfile/SupabaseUserProfileProvider.d.ts +16 -0
- package/dist/userProfile/SupabaseUserProfileProvider.d.ts.map +1 -0
- package/dist/userProfile/SupabaseUserProfileProvider.js +148 -0
- package/dist/userProfile/SupabaseUserProfileProvider.js.map +1 -0
- package/dist/userProfile/__tests__/conformance.test.d.ts +2 -0
- package/dist/userProfile/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/userProfile/__tests__/conformance.test.js +38 -0
- package/dist/userProfile/__tests__/conformance.test.js.map +1 -0
- package/dist/userProfile/index.d.ts +2 -0
- package/dist/userProfile/index.d.ts.map +1 -0
- package/dist/userProfile/index.js +2 -0
- package/dist/userProfile/index.js.map +1 -0
- package/package.json +75 -0
- package/src/auth/SupabaseAuthProvider.ts +464 -0
- package/src/auth/__tests__/conformance.test.ts +78 -0
- package/src/auth/index.ts +2 -0
- package/src/data/SupabaseAuditQuery.ts +103 -0
- package/src/data/SupabaseComputeServerStore.ts +262 -0
- package/src/data/SupabaseDataProvider.ts +125 -0
- package/src/data/SupabaseDefinitionStore.ts +504 -0
- package/src/data/SupabaseEventSink.ts +30 -0
- package/src/data/SupabaseInviteStore.ts +183 -0
- package/src/data/SupabaseOrgStore.ts +547 -0
- package/src/data/SupabaseProjectStore.ts +414 -0
- package/src/data/SupabaseShareLinkStore.ts +191 -0
- package/src/data/__tests__/compute-server-conformance.test.ts +40 -0
- package/src/data/__tests__/definition-conformance.test.ts +89 -0
- package/src/data/__tests__/event-sink-conformance.test.ts +32 -0
- package/src/data/__tests__/invite-conformance.test.ts +46 -0
- package/src/data/__tests__/org-conformance.test.ts +30 -0
- package/src/data/__tests__/project-conformance.test.ts +57 -0
- package/src/data/__tests__/share-link-conformance.test.ts +87 -0
- package/src/data/__tests__/test-helpers.ts +186 -0
- package/src/data/client.ts +86 -0
- package/src/data/index.ts +11 -0
- package/src/data/pagination.ts +68 -0
- package/src/index.ts +23 -0
- package/src/permissions/SupabasePlatformPermissionStore.ts +161 -0
- package/src/permissions/__tests__/conformance.test.ts +28 -0
- package/src/permissions/index.ts +1 -0
- package/src/storage/SupabaseStorageProvider.ts +177 -0
- package/src/storage/__tests__/conformance.test.ts +152 -0
- package/src/storage/index.ts +2 -0
- package/src/userProfile/SupabaseUserProfileProvider.ts +177 -0
- package/src/userProfile/__tests__/conformance.test.ts +39 -0
- package/src/userProfile/index.ts +1 -0
- package/supabase/.branches/_current_branch +1 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/config.toml +68 -0
- package/supabase/migrations/0001_initial.sql +1084 -0
- package/supabase/seed.sql +13 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IDefinitionStore,
|
|
3
|
+
IEventSink,
|
|
4
|
+
DefinitionRecord,
|
|
5
|
+
DefinitionRecordPatch,
|
|
6
|
+
DefinitionStatus,
|
|
7
|
+
DefinitionFileExt,
|
|
8
|
+
DefinitionVersion,
|
|
9
|
+
RequestContext,
|
|
10
|
+
DefinitionListOptions,
|
|
11
|
+
ListOptions,
|
|
12
|
+
Page
|
|
13
|
+
} from '@selvajs/platform';
|
|
14
|
+
import { ProviderError, auditSoftDelete, actorFrom, NoopEventSink } from '@selvajs/platform';
|
|
15
|
+
import type { ClientBundle } from './client.js';
|
|
16
|
+
import { nextCursorFromRange, toRange } from './pagination.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Definition metadata + version store backed by Postgres. Spec §6 versioning:
|
|
20
|
+
* `definitions` carries the parent record and the `live`/`draft` channel
|
|
21
|
+
* pointers; `definition_versions` carries the immutable per-upload rows.
|
|
22
|
+
*
|
|
23
|
+
* Deletion protection (§6) is enforced by FK constraints — `live_version_id`
|
|
24
|
+
* and `draft_version_id` are ON DELETE RESTRICT. Trying to delete a
|
|
25
|
+
* referenced version raises 23503 which `mapError` turns into a 409.
|
|
26
|
+
*
|
|
27
|
+
* `incrementSolveCount` uses a SQL function for atomic UPDATE — no
|
|
28
|
+
* read-modify-write race like the local provider has.
|
|
29
|
+
*/
|
|
30
|
+
export class SupabaseDefinitionStore implements IDefinitionStore {
|
|
31
|
+
private readonly events: IEventSink;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly clients: ClientBundle,
|
|
35
|
+
events: IEventSink = new NoopEventSink()
|
|
36
|
+
) {
|
|
37
|
+
this.events = events;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ============================================================================
|
|
41
|
+
// Definitions
|
|
42
|
+
// ============================================================================
|
|
43
|
+
|
|
44
|
+
async list(ctx: RequestContext, opts?: DefinitionListOptions): Promise<Page<DefinitionRecord>> {
|
|
45
|
+
return this.runList(ctx, undefined, opts);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async listByProject(
|
|
49
|
+
ctx: RequestContext,
|
|
50
|
+
projectId: string,
|
|
51
|
+
opts?: DefinitionListOptions
|
|
52
|
+
): Promise<Page<DefinitionRecord>> {
|
|
53
|
+
return this.runList(ctx, { projectId }, opts);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async listPublic(
|
|
57
|
+
ctx: RequestContext,
|
|
58
|
+
opts?: DefinitionListOptions & { orgId?: string }
|
|
59
|
+
): Promise<Page<DefinitionRecord>> {
|
|
60
|
+
return this.runList(ctx, { publicOnly: true, orgId: opts?.orgId }, opts);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async runList(
|
|
64
|
+
ctx: RequestContext,
|
|
65
|
+
filter: { projectId?: string; publicOnly?: boolean; orgId?: string } | undefined,
|
|
66
|
+
opts?: DefinitionListOptions
|
|
67
|
+
): Promise<Page<DefinitionRecord>> {
|
|
68
|
+
const range = toRange(opts);
|
|
69
|
+
const direction = opts?.orderDir ?? 'desc';
|
|
70
|
+
|
|
71
|
+
let query = this.clients
|
|
72
|
+
.forRequest(ctx)
|
|
73
|
+
.from('definitions')
|
|
74
|
+
.select('*', { count: 'exact' })
|
|
75
|
+
.is('deleted_at', null);
|
|
76
|
+
|
|
77
|
+
if (filter?.projectId) query = query.eq('project_id', filter.projectId);
|
|
78
|
+
|
|
79
|
+
if (filter?.publicOnly) {
|
|
80
|
+
query = this.clients
|
|
81
|
+
.forRequest(ctx)
|
|
82
|
+
.from('definitions')
|
|
83
|
+
.select('*, project:projects!inner(visibility, org_id)', {
|
|
84
|
+
count: 'exact'
|
|
85
|
+
})
|
|
86
|
+
.is('deleted_at', null)
|
|
87
|
+
.eq('project.visibility', 'public');
|
|
88
|
+
if (filter.orgId) query = query.eq('project.org_id', filter.orgId);
|
|
89
|
+
if (filter.projectId) query = query.eq('project_id', filter.projectId);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (opts?.statuses?.length) {
|
|
93
|
+
query = query.in('status', opts.statuses);
|
|
94
|
+
} else {
|
|
95
|
+
const excluded: string[] = [];
|
|
96
|
+
if (!opts?.includePending) excluded.push('pending');
|
|
97
|
+
if (!opts?.includeArchived) excluded.push('archived');
|
|
98
|
+
if (excluded.length) {
|
|
99
|
+
query = query.not('status', 'in', `(${excluded.join(',')})`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
query = query
|
|
104
|
+
.order(definitionOrderColumn(opts?.orderBy), { ascending: direction === 'asc' })
|
|
105
|
+
.range(range.from, range.to);
|
|
106
|
+
|
|
107
|
+
const { data, error, count } = await query;
|
|
108
|
+
if (error) throw mapError(error);
|
|
109
|
+
const items = (data ?? []).map(rowToRecord);
|
|
110
|
+
return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async get(ctx: RequestContext, guid: string): Promise<DefinitionRecord | null> {
|
|
114
|
+
const { data, error } = await this.clients
|
|
115
|
+
.forRequest(ctx)
|
|
116
|
+
.from('definitions')
|
|
117
|
+
.select('*')
|
|
118
|
+
.eq('guid', guid)
|
|
119
|
+
.is('deleted_at', null)
|
|
120
|
+
.maybeSingle();
|
|
121
|
+
if (error) throw mapError(error);
|
|
122
|
+
return data ? rowToRecord(data) : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async create(ctx: RequestContext, record: DefinitionRecord): Promise<void> {
|
|
126
|
+
const { error } = await this.clients
|
|
127
|
+
.forRequest(ctx)
|
|
128
|
+
.from('definitions')
|
|
129
|
+
.insert(recordToRow(record));
|
|
130
|
+
if (error) throw mapError(error);
|
|
131
|
+
await this.events.emit({
|
|
132
|
+
type: 'definition.created',
|
|
133
|
+
definitionId: record.guid,
|
|
134
|
+
projectId: record.projectId,
|
|
135
|
+
actorId: actorFrom(ctx)
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async update(ctx: RequestContext, guid: string, patch: DefinitionRecordPatch): Promise<void> {
|
|
140
|
+
const row = patchToRow(patch);
|
|
141
|
+
if (Object.keys(row).length === 0) return;
|
|
142
|
+
if (ctx.userId) row.updated_by = ctx.userId;
|
|
143
|
+
|
|
144
|
+
const { data, error } = await this.clients
|
|
145
|
+
.forRequest(ctx)
|
|
146
|
+
.from('definitions')
|
|
147
|
+
.update(row)
|
|
148
|
+
.eq('guid', guid)
|
|
149
|
+
.is('deleted_at', null)
|
|
150
|
+
.select('guid');
|
|
151
|
+
if (error) throw mapError(error);
|
|
152
|
+
if (!data || data.length === 0) throw new ProviderError(`Definition '${guid}' not found`, 404);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async delete(ctx: RequestContext, guid: string): Promise<void> {
|
|
156
|
+
// Soft-delete (matches the local provider and spec §9). A retention
|
|
157
|
+
// sweep running as service-role hard-deletes later, which cascades
|
|
158
|
+
// versions + share_links via FK.
|
|
159
|
+
const stamp = auditSoftDelete(ctx, ctx.userId);
|
|
160
|
+
const row: Record<string, unknown> = {
|
|
161
|
+
deleted_at: stamp.deletedAt,
|
|
162
|
+
updated_at: stamp.updatedAt
|
|
163
|
+
};
|
|
164
|
+
if (stamp.updatedBy) row.updated_by = stamp.updatedBy;
|
|
165
|
+
const { error } = await this.clients
|
|
166
|
+
.forRequest(ctx)
|
|
167
|
+
.from('definitions')
|
|
168
|
+
.update(row)
|
|
169
|
+
.eq('guid', guid)
|
|
170
|
+
.is('deleted_at', null);
|
|
171
|
+
if (error) throw mapError(error);
|
|
172
|
+
await this.events.emit({
|
|
173
|
+
type: 'definition.deleted',
|
|
174
|
+
definitionId: guid,
|
|
175
|
+
actorId: actorFrom(ctx)
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async incrementSolveCount(ctx: RequestContext, guid: string): Promise<void> {
|
|
180
|
+
const { error } = await this.clients.forRequest(ctx).rpc('increment_run_count', { g: guid });
|
|
181
|
+
if (error) throw mapError(error);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ============================================================================
|
|
185
|
+
// Versions (spec §6)
|
|
186
|
+
// ============================================================================
|
|
187
|
+
|
|
188
|
+
async createVersion(ctx: RequestContext, version: DefinitionVersion): Promise<void> {
|
|
189
|
+
const { error } = await this.clients
|
|
190
|
+
.forRequest(ctx)
|
|
191
|
+
.from('definition_versions')
|
|
192
|
+
.insert(versionToRow(version));
|
|
193
|
+
if (error) throw mapError(error);
|
|
194
|
+
await this.events.emit({
|
|
195
|
+
type: 'definition_version.created',
|
|
196
|
+
versionId: version.id,
|
|
197
|
+
definitionId: version.definitionId,
|
|
198
|
+
actorId: actorFrom(ctx)
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async listVersions(
|
|
203
|
+
ctx: RequestContext,
|
|
204
|
+
definitionId: string,
|
|
205
|
+
opts?: ListOptions
|
|
206
|
+
): Promise<Page<DefinitionVersion>> {
|
|
207
|
+
const range = toRange(opts);
|
|
208
|
+
const { data, error, count } = await this.clients
|
|
209
|
+
.forRequest(ctx)
|
|
210
|
+
.from('definition_versions')
|
|
211
|
+
.select('*', { count: 'exact' })
|
|
212
|
+
.eq('definition_guid', definitionId)
|
|
213
|
+
.order('version_number', { ascending: false })
|
|
214
|
+
.range(range.from, range.to);
|
|
215
|
+
if (error) throw mapError(error);
|
|
216
|
+
const items = (data ?? []).map(rowToVersion);
|
|
217
|
+
return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async getVersion(ctx: RequestContext, versionId: string): Promise<DefinitionVersion | null> {
|
|
221
|
+
const { data, error } = await this.clients
|
|
222
|
+
.forRequest(ctx)
|
|
223
|
+
.from('definition_versions')
|
|
224
|
+
.select('*')
|
|
225
|
+
.eq('id', versionId)
|
|
226
|
+
.maybeSingle();
|
|
227
|
+
if (error) throw mapError(error);
|
|
228
|
+
return data ? rowToVersion(data) : null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async deleteVersion(ctx: RequestContext, versionId: string): Promise<void> {
|
|
232
|
+
// FK enforcement at the DB layer: live_version_id / draft_version_id
|
|
233
|
+
// are ON DELETE RESTRICT. If the version is referenced by either
|
|
234
|
+
// channel, Postgres raises 23503 → mapError → ProviderError(409).
|
|
235
|
+
const { error } = await this.clients
|
|
236
|
+
.forRequest(ctx)
|
|
237
|
+
.from('definition_versions')
|
|
238
|
+
.delete()
|
|
239
|
+
.eq('id', versionId);
|
|
240
|
+
if (error) throw mapError(error);
|
|
241
|
+
await this.events.emit({
|
|
242
|
+
type: 'definition_version.deleted',
|
|
243
|
+
versionId,
|
|
244
|
+
actorId: actorFrom(ctx)
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async setLiveVersion(
|
|
249
|
+
ctx: RequestContext,
|
|
250
|
+
definitionId: string,
|
|
251
|
+
versionId: string
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
await this.repointChannel(ctx, definitionId, versionId, 'live_version_id');
|
|
254
|
+
// Only `live` advancement is the published-event trigger. Draft
|
|
255
|
+
// repointing is the editor's working pointer, not a publication signal.
|
|
256
|
+
await this.events.emit({
|
|
257
|
+
type: 'definition.published',
|
|
258
|
+
definitionId,
|
|
259
|
+
versionId,
|
|
260
|
+
actorId: actorFrom(ctx)
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async setDraftVersion(
|
|
265
|
+
ctx: RequestContext,
|
|
266
|
+
definitionId: string,
|
|
267
|
+
versionId: string
|
|
268
|
+
): Promise<void> {
|
|
269
|
+
await this.repointChannel(ctx, definitionId, versionId, 'draft_version_id');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async attachInitialVersion(
|
|
273
|
+
ctx: RequestContext,
|
|
274
|
+
definitionId: string,
|
|
275
|
+
versionId: string
|
|
276
|
+
): Promise<void> {
|
|
277
|
+
const client = this.clients.forRequest(ctx);
|
|
278
|
+
// Validate the version belongs to this definition before repointing —
|
|
279
|
+
// matches `repointChannel` so the bootstrap surface enforces the same
|
|
280
|
+
// invariant.
|
|
281
|
+
const { data: version, error: vError } = await client
|
|
282
|
+
.from('definition_versions')
|
|
283
|
+
.select('id, definition_guid')
|
|
284
|
+
.eq('id', versionId)
|
|
285
|
+
.maybeSingle();
|
|
286
|
+
if (vError) throw mapError(vError);
|
|
287
|
+
if (!version || version.definition_guid !== definitionId) {
|
|
288
|
+
throw new ProviderError(`Version '${versionId}' not found for this definition`, 404);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Single UPDATE — both channel pointers and status flip in one row
|
|
292
|
+
// write, so a mid-flight failure can't leave a half-promoted record.
|
|
293
|
+
const row: Record<string, unknown> = {
|
|
294
|
+
live_version_id: versionId,
|
|
295
|
+
draft_version_id: versionId,
|
|
296
|
+
status: 'draft'
|
|
297
|
+
};
|
|
298
|
+
if (ctx.userId) row.updated_by = ctx.userId;
|
|
299
|
+
|
|
300
|
+
const { data, error } = await client
|
|
301
|
+
.from('definitions')
|
|
302
|
+
.update(row)
|
|
303
|
+
.eq('guid', definitionId)
|
|
304
|
+
.is('deleted_at', null)
|
|
305
|
+
.select('guid');
|
|
306
|
+
if (error) throw mapError(error);
|
|
307
|
+
if (!data || data.length === 0) {
|
|
308
|
+
throw new ProviderError(`Definition '${definitionId}' not found`, 404);
|
|
309
|
+
}
|
|
310
|
+
// No `definition.published` event — see interface doc.
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async repointChannel(
|
|
314
|
+
ctx: RequestContext,
|
|
315
|
+
definitionId: string,
|
|
316
|
+
versionId: string,
|
|
317
|
+
column: 'live_version_id' | 'draft_version_id'
|
|
318
|
+
): Promise<void> {
|
|
319
|
+
const client = this.clients.forRequest(ctx);
|
|
320
|
+
// Validate the version belongs to this definition before repointing.
|
|
321
|
+
const { data: version, error: vError } = await client
|
|
322
|
+
.from('definition_versions')
|
|
323
|
+
.select('id, definition_guid')
|
|
324
|
+
.eq('id', versionId)
|
|
325
|
+
.maybeSingle();
|
|
326
|
+
if (vError) throw mapError(vError);
|
|
327
|
+
if (!version || version.definition_guid !== definitionId) {
|
|
328
|
+
throw new ProviderError(`Version '${versionId}' not found for this definition`, 404);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const row: Record<string, unknown> = { [column]: versionId };
|
|
332
|
+
if (ctx.userId) row.updated_by = ctx.userId;
|
|
333
|
+
const { data, error } = await client
|
|
334
|
+
.from('definitions')
|
|
335
|
+
.update(row)
|
|
336
|
+
.eq('guid', definitionId)
|
|
337
|
+
.is('deleted_at', null)
|
|
338
|
+
.select('guid');
|
|
339
|
+
if (error) throw mapError(error);
|
|
340
|
+
if (!data || data.length === 0) {
|
|
341
|
+
throw new ProviderError(`Definition '${definitionId}' not found`, 404);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ============================================================================
|
|
347
|
+
// Row ↔ domain mappers
|
|
348
|
+
// ============================================================================
|
|
349
|
+
interface DefinitionRow {
|
|
350
|
+
guid: string;
|
|
351
|
+
project_id: string;
|
|
352
|
+
owner_id: string;
|
|
353
|
+
created_by?: string | null;
|
|
354
|
+
updated_by?: string | null;
|
|
355
|
+
compute_server_id: string | null;
|
|
356
|
+
display_name: string;
|
|
357
|
+
description: string | null;
|
|
358
|
+
category: string | null;
|
|
359
|
+
tags: string[] | null;
|
|
360
|
+
cover_image: string | null;
|
|
361
|
+
status: DefinitionStatus;
|
|
362
|
+
run_count: number | string;
|
|
363
|
+
live_version_id: string | null;
|
|
364
|
+
draft_version_id: string | null;
|
|
365
|
+
created_at: string;
|
|
366
|
+
updated_at: string;
|
|
367
|
+
deleted_at?: string | null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
interface DefinitionVersionRow {
|
|
371
|
+
id: string;
|
|
372
|
+
definition_guid: string;
|
|
373
|
+
version_number: number;
|
|
374
|
+
file_ext: DefinitionFileExt;
|
|
375
|
+
file_key: string;
|
|
376
|
+
original_filename: string | null;
|
|
377
|
+
uploaded_by: string;
|
|
378
|
+
uploaded_at: string;
|
|
379
|
+
change_note?: string | null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function rowToRecord(row: DefinitionRow): DefinitionRecord {
|
|
383
|
+
return {
|
|
384
|
+
guid: row.guid,
|
|
385
|
+
projectId: row.project_id,
|
|
386
|
+
ownerId: row.owner_id,
|
|
387
|
+
createdBy: row.created_by ?? row.owner_id,
|
|
388
|
+
updatedBy: row.updated_by ?? row.owner_id,
|
|
389
|
+
liveVersionId: row.live_version_id,
|
|
390
|
+
draftVersionId: row.draft_version_id,
|
|
391
|
+
computeServerId: row.compute_server_id ?? undefined,
|
|
392
|
+
displayName: row.display_name,
|
|
393
|
+
description: row.description ?? undefined,
|
|
394
|
+
category: row.category ?? undefined,
|
|
395
|
+
tags: row.tags ?? undefined,
|
|
396
|
+
coverImage: row.cover_image ?? undefined,
|
|
397
|
+
status: row.status,
|
|
398
|
+
solveCount: typeof row.run_count === 'string' ? Number(row.run_count) : row.run_count,
|
|
399
|
+
createdAt: row.created_at,
|
|
400
|
+
updatedAt: row.updated_at,
|
|
401
|
+
deletedAt: row.deleted_at ?? null
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function recordToRow(r: DefinitionRecord): Record<string, unknown> {
|
|
406
|
+
const row: Record<string, unknown> = {
|
|
407
|
+
guid: r.guid,
|
|
408
|
+
project_id: r.projectId,
|
|
409
|
+
owner_id: r.ownerId,
|
|
410
|
+
created_by: r.createdBy,
|
|
411
|
+
updated_by: r.updatedBy,
|
|
412
|
+
live_version_id: r.liveVersionId,
|
|
413
|
+
draft_version_id: r.draftVersionId,
|
|
414
|
+
compute_server_id: r.computeServerId ?? null,
|
|
415
|
+
display_name: r.displayName,
|
|
416
|
+
description: r.description ?? null,
|
|
417
|
+
category: r.category ?? null,
|
|
418
|
+
cover_image: r.coverImage ?? null,
|
|
419
|
+
status: r.status,
|
|
420
|
+
run_count: r.solveCount,
|
|
421
|
+
created_at: r.createdAt,
|
|
422
|
+
updated_at: r.updatedAt,
|
|
423
|
+
deleted_at: r.deletedAt ?? null
|
|
424
|
+
};
|
|
425
|
+
if (r.tags !== undefined) row.tags = r.tags;
|
|
426
|
+
return row;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function patchToRow(patch: DefinitionRecordPatch): Record<string, unknown> {
|
|
430
|
+
const row: Record<string, unknown> = {};
|
|
431
|
+
if (patch.displayName !== undefined) row.display_name = patch.displayName;
|
|
432
|
+
if (patch.description !== undefined) row.description = patch.description;
|
|
433
|
+
if (patch.category !== undefined) row.category = patch.category;
|
|
434
|
+
if (patch.tags !== undefined) row.tags = patch.tags;
|
|
435
|
+
if (patch.coverImage !== undefined) row.cover_image = patch.coverImage;
|
|
436
|
+
if (patch.projectId !== undefined) row.project_id = patch.projectId;
|
|
437
|
+
if (patch.computeServerId !== undefined) row.compute_server_id = patch.computeServerId;
|
|
438
|
+
if (patch.status !== undefined) row.status = patch.status;
|
|
439
|
+
if (patch.ownerId !== undefined) row.owner_id = patch.ownerId;
|
|
440
|
+
return row;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function rowToVersion(row: DefinitionVersionRow): DefinitionVersion {
|
|
444
|
+
return {
|
|
445
|
+
id: row.id,
|
|
446
|
+
definitionId: row.definition_guid,
|
|
447
|
+
versionNumber: row.version_number,
|
|
448
|
+
fileExt: row.file_ext,
|
|
449
|
+
fileKey: row.file_key,
|
|
450
|
+
originalFilename: row.original_filename ?? undefined,
|
|
451
|
+
uploadedBy: row.uploaded_by,
|
|
452
|
+
uploadedAt: row.uploaded_at,
|
|
453
|
+
changeNote: row.change_note ?? undefined
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function versionToRow(v: DefinitionVersion): Record<string, unknown> {
|
|
458
|
+
const row: Record<string, unknown> = {
|
|
459
|
+
id: v.id,
|
|
460
|
+
definition_guid: v.definitionId,
|
|
461
|
+
version_number: v.versionNumber,
|
|
462
|
+
file_ext: v.fileExt,
|
|
463
|
+
file_key: v.fileKey,
|
|
464
|
+
original_filename: v.originalFilename ?? null,
|
|
465
|
+
uploaded_by: v.uploadedBy,
|
|
466
|
+
uploaded_at: v.uploadedAt
|
|
467
|
+
};
|
|
468
|
+
if (v.changeNote !== undefined) row.change_note = v.changeNote;
|
|
469
|
+
return row;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function definitionOrderColumn(orderBy: DefinitionListOptions['orderBy'] | undefined): string {
|
|
473
|
+
switch (orderBy) {
|
|
474
|
+
case 'name':
|
|
475
|
+
return 'display_name';
|
|
476
|
+
case 'solveCount':
|
|
477
|
+
return 'run_count';
|
|
478
|
+
case 'updatedAt':
|
|
479
|
+
return 'updated_at';
|
|
480
|
+
case 'createdAt':
|
|
481
|
+
default:
|
|
482
|
+
return 'created_at';
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
interface PostgrestError {
|
|
487
|
+
code?: string;
|
|
488
|
+
message?: string;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function mapError(e: unknown): Error {
|
|
492
|
+
const pg = e as PostgrestError;
|
|
493
|
+
if (pg?.code === '23505') return new ProviderError(pg.message ?? 'Duplicate record', 409);
|
|
494
|
+
if (pg?.code === '23503') return new ProviderError(pg.message ?? 'Foreign key violation', 409);
|
|
495
|
+
if (e instanceof Error) return e;
|
|
496
|
+
if (e && typeof e === 'object') {
|
|
497
|
+
const obj = e as { message?: string; details?: string; hint?: string; code?: string };
|
|
498
|
+
const msg = obj.message ?? obj.details ?? obj.hint ?? 'Unknown Postgres error';
|
|
499
|
+
const err = new Error(obj.code ? `[${obj.code}] ${msg}` : msg);
|
|
500
|
+
Object.assign(err, obj);
|
|
501
|
+
return err;
|
|
502
|
+
}
|
|
503
|
+
return new Error(String(e));
|
|
504
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DomainEvent, IEventSink } from '@selvajs/platform';
|
|
2
|
+
import type { ClientBundle } from './client.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Persists every domain event to `public.audit_events`. Writes use the
|
|
6
|
+
* service-role client so RLS does not gate the audit trail — the sink runs
|
|
7
|
+
* as a system process attached to whichever store emitted the event.
|
|
8
|
+
*
|
|
9
|
+
* Per the `IEventSink` contract, `emit` MUST NOT throw. The user-facing
|
|
10
|
+
* mutation has already succeeded by the time we're here; a failed audit
|
|
11
|
+
* write is logged and swallowed so the response still completes.
|
|
12
|
+
*/
|
|
13
|
+
export class SupabaseEventSink implements IEventSink {
|
|
14
|
+
constructor(private readonly clients: ClientBundle) {}
|
|
15
|
+
|
|
16
|
+
async emit(event: DomainEvent): Promise<void> {
|
|
17
|
+
try {
|
|
18
|
+
const { error } = await this.clients.serviceClient.from('audit_events').insert({
|
|
19
|
+
type: event.type,
|
|
20
|
+
actor_id: event.actorId,
|
|
21
|
+
data: event
|
|
22
|
+
});
|
|
23
|
+
if (error) {
|
|
24
|
+
console.error('[SupabaseEventSink] insert failed:', error.message, { event });
|
|
25
|
+
}
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error('[SupabaseEventSink] unexpected error:', err, { event });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|