@syncular/testkit 0.0.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/package.json +65 -0
- package/src/assertions.ts +432 -0
- package/src/faults.ts +229 -0
- package/src/fixtures.ts +849 -0
- package/src/hono-node-server.ts +86 -0
- package/src/http-fixtures.ts +213 -0
- package/src/index.ts +6 -0
- package/src/project-scoped-tasks.ts +299 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createServer, type Server as NodeServer } from 'node:http';
|
|
2
|
+
import type { Hono } from 'hono';
|
|
3
|
+
|
|
4
|
+
export interface NodeHonoServerOptions {
|
|
5
|
+
cors?: boolean;
|
|
6
|
+
corsAllowMethods?: string;
|
|
7
|
+
corsAllowHeaders?: string;
|
|
8
|
+
corsMaxAgeSeconds?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createNodeHonoServer(
|
|
12
|
+
app: Hono,
|
|
13
|
+
options?: NodeHonoServerOptions
|
|
14
|
+
): NodeServer {
|
|
15
|
+
const corsEnabled = options?.cors ?? true;
|
|
16
|
+
const corsAllowMethods =
|
|
17
|
+
options?.corsAllowMethods ?? 'GET, POST, PUT, DELETE, OPTIONS';
|
|
18
|
+
const corsAllowHeaders =
|
|
19
|
+
options?.corsAllowHeaders ??
|
|
20
|
+
'content-type, x-actor-id, x-syncular-transport-path, x-user-id';
|
|
21
|
+
const corsMaxAgeSeconds = options?.corsMaxAgeSeconds ?? 86400;
|
|
22
|
+
|
|
23
|
+
return createServer(async (req, res) => {
|
|
24
|
+
const url = `http://localhost${req.url ?? '/'}`;
|
|
25
|
+
|
|
26
|
+
const headers = new Headers();
|
|
27
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
28
|
+
if (!value) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
headers.set(key, Array.isArray(value) ? value.join(', ') : value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (corsEnabled && req.method === 'OPTIONS') {
|
|
36
|
+
res.writeHead(204, {
|
|
37
|
+
'access-control-allow-origin': '*',
|
|
38
|
+
'access-control-allow-methods': corsAllowMethods,
|
|
39
|
+
'access-control-allow-headers': corsAllowHeaders,
|
|
40
|
+
'access-control-max-age': String(corsMaxAgeSeconds),
|
|
41
|
+
});
|
|
42
|
+
res.end();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
|
47
|
+
const body = hasBody
|
|
48
|
+
? await new Promise<Uint8Array>((resolve) => {
|
|
49
|
+
const chunks: Uint8Array[] = [];
|
|
50
|
+
req.on('data', (chunk: Uint8Array) => chunks.push(chunk));
|
|
51
|
+
req.on('end', () => {
|
|
52
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
53
|
+
const merged = new Uint8Array(total);
|
|
54
|
+
let offset = 0;
|
|
55
|
+
for (const chunk of chunks) {
|
|
56
|
+
merged.set(chunk, offset);
|
|
57
|
+
offset += chunk.length;
|
|
58
|
+
}
|
|
59
|
+
resolve(merged);
|
|
60
|
+
});
|
|
61
|
+
})
|
|
62
|
+
: undefined;
|
|
63
|
+
|
|
64
|
+
const requestBody = body ? Uint8Array.from(body) : undefined;
|
|
65
|
+
|
|
66
|
+
const request = new Request(url, {
|
|
67
|
+
method: req.method,
|
|
68
|
+
headers,
|
|
69
|
+
body: requestBody,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const response = await app.fetch(request);
|
|
73
|
+
const responseHeaders: Record<string, string> = {};
|
|
74
|
+
response.headers.forEach((value, key) => {
|
|
75
|
+
responseHeaders[key] = value;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (corsEnabled) {
|
|
79
|
+
responseHeaders['access-control-allow-origin'] = '*';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
res.writeHead(response.status, responseHeaders);
|
|
83
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
84
|
+
res.end(bytes);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ClientTableRegistry,
|
|
3
|
+
enqueueOutboxCommit,
|
|
4
|
+
ensureClientSyncSchema,
|
|
5
|
+
type SyncClientDb,
|
|
6
|
+
type SyncOnceOptions,
|
|
7
|
+
type SyncOnceResult,
|
|
8
|
+
type SyncPullOnceOptions,
|
|
9
|
+
type SyncPullResponse,
|
|
10
|
+
type SyncPushOnceOptions,
|
|
11
|
+
type SyncPushOnceResult,
|
|
12
|
+
syncOnce,
|
|
13
|
+
syncPullOnce,
|
|
14
|
+
syncPushOnce,
|
|
15
|
+
} from '@syncular/client';
|
|
16
|
+
import type { SyncTransport } from '@syncular/core';
|
|
17
|
+
import { createBunSqliteDb } from '@syncular/dialect-bun-sqlite';
|
|
18
|
+
import { createPgliteDb } from '@syncular/dialect-pglite';
|
|
19
|
+
import {
|
|
20
|
+
ensureSyncSchema,
|
|
21
|
+
type ServerSyncDialect,
|
|
22
|
+
type ServerTableHandler,
|
|
23
|
+
type SyncCoreDb,
|
|
24
|
+
} from '@syncular/server';
|
|
25
|
+
import { createPostgresServerDialect } from '@syncular/server-dialect-postgres';
|
|
26
|
+
import { createSqliteServerDialect } from '@syncular/server-dialect-sqlite';
|
|
27
|
+
import {
|
|
28
|
+
type CreateSyncRoutesOptions,
|
|
29
|
+
createSyncRoutes,
|
|
30
|
+
} from '@syncular/server-hono';
|
|
31
|
+
import { createHttpTransport } from '@syncular/transport-http';
|
|
32
|
+
import { Hono } from 'hono';
|
|
33
|
+
import type { Kysely } from 'kysely';
|
|
34
|
+
import { createNodeHonoServer } from './hono-node-server';
|
|
35
|
+
|
|
36
|
+
export type HttpServerDialect = 'sqlite' | 'pglite';
|
|
37
|
+
export type HttpClientDialect = 'bun-sqlite' | 'pglite';
|
|
38
|
+
|
|
39
|
+
export interface HttpServerFixture<DB extends SyncCoreDb> {
|
|
40
|
+
db: Kysely<DB>;
|
|
41
|
+
dialect: ServerSyncDialect;
|
|
42
|
+
app: Hono;
|
|
43
|
+
httpServer: ReturnType<typeof createNodeHonoServer>;
|
|
44
|
+
baseUrl: string;
|
|
45
|
+
destroy: () => Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CreateHttpServerFixtureOptions<DB extends SyncCoreDb> {
|
|
49
|
+
serverDialect: HttpServerDialect;
|
|
50
|
+
createTables: (db: Kysely<DB>) => Promise<void>;
|
|
51
|
+
handlers: ServerTableHandler<DB>[];
|
|
52
|
+
authenticate: CreateSyncRoutesOptions<DB>['authenticate'];
|
|
53
|
+
sync?: CreateSyncRoutesOptions<DB>['sync'];
|
|
54
|
+
routePath?: string;
|
|
55
|
+
cors?: boolean;
|
|
56
|
+
corsAllowMethods?: string;
|
|
57
|
+
corsAllowHeaders?: string;
|
|
58
|
+
corsMaxAgeSeconds?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface HttpClientFixture<DB extends SyncClientDb> {
|
|
62
|
+
db: Kysely<DB>;
|
|
63
|
+
transport: SyncTransport;
|
|
64
|
+
handlers: ClientTableRegistry<DB>;
|
|
65
|
+
actorId: string;
|
|
66
|
+
clientId: string;
|
|
67
|
+
enqueue: (
|
|
68
|
+
args: Parameters<typeof enqueueOutboxCommit<DB>>[1]
|
|
69
|
+
) => Promise<{ id: string; clientCommitId: string }>;
|
|
70
|
+
push: (
|
|
71
|
+
options?: Omit<SyncPushOnceOptions, 'clientId' | 'actorId'>
|
|
72
|
+
) => Promise<SyncPushOnceResult>;
|
|
73
|
+
pull: (
|
|
74
|
+
options: Omit<SyncPullOnceOptions, 'clientId' | 'actorId'>
|
|
75
|
+
) => Promise<SyncPullResponse>;
|
|
76
|
+
syncOnce: (
|
|
77
|
+
options: Omit<SyncOnceOptions, 'clientId' | 'actorId'>
|
|
78
|
+
) => Promise<SyncOnceResult>;
|
|
79
|
+
destroy: () => Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface CreateHttpClientFixtureOptions<DB extends SyncClientDb> {
|
|
83
|
+
clientDialect: HttpClientDialect;
|
|
84
|
+
baseUrl: string;
|
|
85
|
+
actorId: string;
|
|
86
|
+
clientId: string;
|
|
87
|
+
createTables: (db: Kysely<DB>) => Promise<void>;
|
|
88
|
+
registerHandlers: (handlers: ClientTableRegistry<DB>) => void;
|
|
89
|
+
fetch?: typeof globalThis.fetch;
|
|
90
|
+
getHeaders?: () => Record<string, string>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function createHttpServerFixture<DB extends SyncCoreDb>(
|
|
94
|
+
options: CreateHttpServerFixtureOptions<DB>
|
|
95
|
+
): Promise<HttpServerFixture<DB>> {
|
|
96
|
+
const db =
|
|
97
|
+
options.serverDialect === 'pglite'
|
|
98
|
+
? createPgliteDb<DB>()
|
|
99
|
+
: createBunSqliteDb<DB>({ path: ':memory:' });
|
|
100
|
+
|
|
101
|
+
const dialect =
|
|
102
|
+
options.serverDialect === 'pglite'
|
|
103
|
+
? createPostgresServerDialect()
|
|
104
|
+
: createSqliteServerDialect();
|
|
105
|
+
|
|
106
|
+
await ensureSyncSchema(db, dialect);
|
|
107
|
+
if (dialect.ensureConsoleSchema) {
|
|
108
|
+
await dialect.ensureConsoleSchema(db);
|
|
109
|
+
}
|
|
110
|
+
await options.createTables(db);
|
|
111
|
+
|
|
112
|
+
const app = new Hono();
|
|
113
|
+
const routePath = options.routePath ?? '/sync';
|
|
114
|
+
|
|
115
|
+
const syncRoutes = createSyncRoutes<DB>({
|
|
116
|
+
db,
|
|
117
|
+
dialect,
|
|
118
|
+
handlers: options.handlers,
|
|
119
|
+
authenticate: options.authenticate,
|
|
120
|
+
sync: options.sync,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
app.route(routePath, syncRoutes);
|
|
124
|
+
|
|
125
|
+
const httpServer = createNodeHonoServer(app, {
|
|
126
|
+
cors: options.cors,
|
|
127
|
+
corsAllowMethods: options.corsAllowMethods,
|
|
128
|
+
corsAllowHeaders: options.corsAllowHeaders,
|
|
129
|
+
corsMaxAgeSeconds: options.corsMaxAgeSeconds,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await new Promise<void>((resolve) => {
|
|
133
|
+
httpServer.listen(0, resolve);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const address = httpServer.address();
|
|
137
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
db,
|
|
141
|
+
dialect,
|
|
142
|
+
app,
|
|
143
|
+
httpServer,
|
|
144
|
+
baseUrl: `http://localhost:${port}`,
|
|
145
|
+
destroy: async () => {
|
|
146
|
+
await new Promise<void>((resolve, reject) => {
|
|
147
|
+
httpServer.close((err) => {
|
|
148
|
+
if (err) {
|
|
149
|
+
reject(err);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
resolve();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
await db.destroy();
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function createHttpClientFixture<DB extends SyncClientDb>(
|
|
161
|
+
options: CreateHttpClientFixtureOptions<DB>
|
|
162
|
+
): Promise<HttpClientFixture<DB>> {
|
|
163
|
+
const db =
|
|
164
|
+
options.clientDialect === 'pglite'
|
|
165
|
+
? createPgliteDb<DB>()
|
|
166
|
+
: createBunSqliteDb<DB>({ path: ':memory:' });
|
|
167
|
+
|
|
168
|
+
await ensureClientSyncSchema(db);
|
|
169
|
+
await options.createTables(db);
|
|
170
|
+
|
|
171
|
+
const handlers = new ClientTableRegistry<DB>();
|
|
172
|
+
options.registerHandlers(handlers);
|
|
173
|
+
|
|
174
|
+
const transport = createHttpTransport({
|
|
175
|
+
baseUrl: options.baseUrl,
|
|
176
|
+
getHeaders:
|
|
177
|
+
options.getHeaders ??
|
|
178
|
+
(() => ({
|
|
179
|
+
'x-actor-id': options.actorId,
|
|
180
|
+
})),
|
|
181
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
db,
|
|
186
|
+
transport,
|
|
187
|
+
handlers,
|
|
188
|
+
actorId: options.actorId,
|
|
189
|
+
clientId: options.clientId,
|
|
190
|
+
enqueue: (args) => enqueueOutboxCommit(db, args),
|
|
191
|
+
push: (pushOptions) =>
|
|
192
|
+
syncPushOnce(db, transport, {
|
|
193
|
+
clientId: options.clientId,
|
|
194
|
+
actorId: options.actorId,
|
|
195
|
+
plugins: pushOptions?.plugins,
|
|
196
|
+
}),
|
|
197
|
+
pull: (pullOptions) =>
|
|
198
|
+
syncPullOnce(db, transport, handlers, {
|
|
199
|
+
...pullOptions,
|
|
200
|
+
clientId: options.clientId,
|
|
201
|
+
actorId: options.actorId,
|
|
202
|
+
}),
|
|
203
|
+
syncOnce: (syncOptions) =>
|
|
204
|
+
syncOnce(db, transport, handlers, {
|
|
205
|
+
...syncOptions,
|
|
206
|
+
clientId: options.clientId,
|
|
207
|
+
actorId: options.actorId,
|
|
208
|
+
}),
|
|
209
|
+
destroy: async () => {
|
|
210
|
+
await db.destroy();
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { isRecord, type SyncOperation } from '@syncular/core';
|
|
2
|
+
import type {
|
|
3
|
+
ApplyOperationResult,
|
|
4
|
+
EmittedChange,
|
|
5
|
+
ServerTableHandler,
|
|
6
|
+
SyncCoreDb,
|
|
7
|
+
} from '@syncular/server';
|
|
8
|
+
import type { Kysely } from 'kysely';
|
|
9
|
+
import { sql } from 'kysely';
|
|
10
|
+
|
|
11
|
+
export interface ProjectScopedTasksRow {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
completed: number;
|
|
15
|
+
user_id: string;
|
|
16
|
+
project_id: string;
|
|
17
|
+
server_version: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ProjectScopedTasksDb extends SyncCoreDb {
|
|
21
|
+
tasks: ProjectScopedTasksRow;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const PROJECT_SCOPED_TASKS_DDL = `
|
|
25
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
26
|
+
id TEXT PRIMARY KEY,
|
|
27
|
+
title TEXT NOT NULL DEFAULT '',
|
|
28
|
+
completed INTEGER NOT NULL DEFAULT 0,
|
|
29
|
+
user_id TEXT NOT NULL,
|
|
30
|
+
project_id TEXT NOT NULL,
|
|
31
|
+
server_version INTEGER NOT NULL DEFAULT 1
|
|
32
|
+
)
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
export interface ProjectScopedTasksHandlerOptions {
|
|
36
|
+
projectScopeCount?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseProjectScopedTaskPayload(payload: SyncOperation['payload']): {
|
|
40
|
+
title?: string;
|
|
41
|
+
completed?: number;
|
|
42
|
+
project_id?: string;
|
|
43
|
+
} {
|
|
44
|
+
if (!isRecord(payload)) {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
title: typeof payload.title === 'string' ? payload.title : undefined,
|
|
50
|
+
completed:
|
|
51
|
+
typeof payload.completed === 'number' ? payload.completed : undefined,
|
|
52
|
+
project_id:
|
|
53
|
+
typeof payload.project_id === 'string' ? payload.project_id : undefined,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function ensureProjectScopedTasksTable<
|
|
58
|
+
DB extends SyncCoreDb & { tasks: ProjectScopedTasksRow },
|
|
59
|
+
>(db: Kysely<DB>): Promise<void> {
|
|
60
|
+
await sql.raw(PROJECT_SCOPED_TASKS_DDL).execute(db);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createProjectScopedTasksHandler<
|
|
64
|
+
DB extends SyncCoreDb & { tasks: ProjectScopedTasksRow },
|
|
65
|
+
>(options: ProjectScopedTasksHandlerOptions = {}): ServerTableHandler<DB> {
|
|
66
|
+
const projectScopeCount = Math.max(1, options.projectScopeCount ?? 100);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
table: 'tasks',
|
|
70
|
+
scopePatterns: ['user:{user_id}:project:{project_id}'],
|
|
71
|
+
|
|
72
|
+
async resolveScopes(ctx) {
|
|
73
|
+
return {
|
|
74
|
+
user_id: ctx.actorId,
|
|
75
|
+
project_id: Array.from(
|
|
76
|
+
{ length: projectScopeCount },
|
|
77
|
+
(_, index) => `p${index}`
|
|
78
|
+
),
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
extractScopes(row: Record<string, unknown>) {
|
|
83
|
+
return {
|
|
84
|
+
user_id: String(row.user_id ?? ''),
|
|
85
|
+
project_id: String(row.project_id ?? ''),
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async snapshot(ctx) {
|
|
90
|
+
const userIdValue = ctx.scopeValues.user_id;
|
|
91
|
+
const projectIdValue = ctx.scopeValues.project_id;
|
|
92
|
+
const userId = Array.isArray(userIdValue) ? userIdValue[0] : userIdValue;
|
|
93
|
+
const projectId = Array.isArray(projectIdValue)
|
|
94
|
+
? projectIdValue[0]
|
|
95
|
+
: projectIdValue;
|
|
96
|
+
|
|
97
|
+
if (!userId || userId !== ctx.actorId) {
|
|
98
|
+
return { rows: [], nextCursor: null };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!projectId) {
|
|
102
|
+
return { rows: [], nextCursor: null };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const pageSize = Math.max(1, Math.min(10_000, ctx.limit));
|
|
106
|
+
const cursor = ctx.cursor;
|
|
107
|
+
|
|
108
|
+
const cursorFilter =
|
|
109
|
+
cursor && cursor.length > 0
|
|
110
|
+
? sql`and ${sql.ref('id')} > ${sql.val(cursor)}`
|
|
111
|
+
: sql``;
|
|
112
|
+
|
|
113
|
+
const result = await sql<ProjectScopedTasksRow>`
|
|
114
|
+
select id, title, completed, user_id, project_id, server_version
|
|
115
|
+
from tasks
|
|
116
|
+
where user_id = ${sql.val(userId)}
|
|
117
|
+
and project_id = ${sql.val(projectId)}
|
|
118
|
+
${cursorFilter}
|
|
119
|
+
order by id asc
|
|
120
|
+
limit ${sql.val(pageSize + 1)}
|
|
121
|
+
`.execute(ctx.db);
|
|
122
|
+
|
|
123
|
+
const rows = result.rows;
|
|
124
|
+
const hasMore = rows.length > pageSize;
|
|
125
|
+
const pageRows = hasMore ? rows.slice(0, pageSize) : rows;
|
|
126
|
+
const nextCursor = hasMore
|
|
127
|
+
? (pageRows[pageRows.length - 1]?.id ?? null)
|
|
128
|
+
: null;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
rows: pageRows,
|
|
132
|
+
nextCursor:
|
|
133
|
+
typeof nextCursor === 'string' && nextCursor.length > 0
|
|
134
|
+
? nextCursor
|
|
135
|
+
: null,
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async applyOperation(
|
|
140
|
+
ctx,
|
|
141
|
+
op: SyncOperation,
|
|
142
|
+
opIndex: number
|
|
143
|
+
): Promise<ApplyOperationResult> {
|
|
144
|
+
if (op.table !== 'tasks') {
|
|
145
|
+
return {
|
|
146
|
+
result: {
|
|
147
|
+
opIndex,
|
|
148
|
+
status: 'error',
|
|
149
|
+
error: `UNKNOWN_TABLE:${op.table}`,
|
|
150
|
+
code: 'UNKNOWN_TABLE',
|
|
151
|
+
retriable: false,
|
|
152
|
+
},
|
|
153
|
+
emittedChanges: [],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (op.op === 'delete') {
|
|
158
|
+
const existingResult = await sql<{ id: string; project_id: string }>`
|
|
159
|
+
select id, project_id from tasks
|
|
160
|
+
where id = ${sql.val(op.row_id)} and user_id = ${sql.val(ctx.actorId)}
|
|
161
|
+
limit 1
|
|
162
|
+
`.execute(ctx.trx);
|
|
163
|
+
const existing = existingResult.rows[0];
|
|
164
|
+
|
|
165
|
+
if (!existing) {
|
|
166
|
+
return { result: { opIndex, status: 'applied' }, emittedChanges: [] };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
await sql`
|
|
170
|
+
delete from tasks
|
|
171
|
+
where id = ${sql.val(op.row_id)} and user_id = ${sql.val(ctx.actorId)}
|
|
172
|
+
`.execute(ctx.trx);
|
|
173
|
+
|
|
174
|
+
const emitted: EmittedChange = {
|
|
175
|
+
table: 'tasks',
|
|
176
|
+
row_id: op.row_id,
|
|
177
|
+
op: 'delete',
|
|
178
|
+
row_json: null,
|
|
179
|
+
row_version: null,
|
|
180
|
+
scopes: { user_id: ctx.actorId, project_id: existing.project_id },
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
result: { opIndex, status: 'applied' },
|
|
185
|
+
emittedChanges: [emitted],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const payload = parseProjectScopedTaskPayload(op.payload);
|
|
190
|
+
|
|
191
|
+
const existingResult = await sql<{
|
|
192
|
+
id: string;
|
|
193
|
+
title: string;
|
|
194
|
+
completed: number;
|
|
195
|
+
project_id: string;
|
|
196
|
+
server_version: number;
|
|
197
|
+
}>`
|
|
198
|
+
select id, title, completed, project_id, server_version
|
|
199
|
+
from tasks
|
|
200
|
+
where id = ${sql.val(op.row_id)} and user_id = ${sql.val(ctx.actorId)}
|
|
201
|
+
limit 1
|
|
202
|
+
`.execute(ctx.trx);
|
|
203
|
+
const existing = existingResult.rows[0];
|
|
204
|
+
|
|
205
|
+
if (
|
|
206
|
+
existing &&
|
|
207
|
+
op.base_version != null &&
|
|
208
|
+
existing.server_version !== op.base_version
|
|
209
|
+
) {
|
|
210
|
+
return {
|
|
211
|
+
result: {
|
|
212
|
+
opIndex,
|
|
213
|
+
status: 'conflict',
|
|
214
|
+
message: `Version conflict: server=${existing.server_version}, base=${op.base_version}`,
|
|
215
|
+
server_version: existing.server_version,
|
|
216
|
+
server_row: {
|
|
217
|
+
id: existing.id,
|
|
218
|
+
title: existing.title,
|
|
219
|
+
completed: existing.completed,
|
|
220
|
+
user_id: ctx.actorId,
|
|
221
|
+
project_id: existing.project_id,
|
|
222
|
+
server_version: existing.server_version,
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
emittedChanges: [],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const projectId = payload.project_id ?? existing?.project_id;
|
|
230
|
+
if (!projectId) {
|
|
231
|
+
return {
|
|
232
|
+
result: {
|
|
233
|
+
opIndex,
|
|
234
|
+
status: 'error',
|
|
235
|
+
error: 'MISSING_PROJECT_ID',
|
|
236
|
+
code: 'INVALID_REQUEST',
|
|
237
|
+
retriable: false,
|
|
238
|
+
},
|
|
239
|
+
emittedChanges: [],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (existing) {
|
|
244
|
+
const nextVersion = existing.server_version + 1;
|
|
245
|
+
await sql`
|
|
246
|
+
update tasks set
|
|
247
|
+
title = ${sql.val(payload.title ?? existing.title)},
|
|
248
|
+
completed = ${sql.val(payload.completed ?? existing.completed)},
|
|
249
|
+
server_version = ${sql.val(nextVersion)}
|
|
250
|
+
where id = ${sql.val(op.row_id)} and user_id = ${sql.val(ctx.actorId)}
|
|
251
|
+
`.execute(ctx.trx);
|
|
252
|
+
} else {
|
|
253
|
+
await sql`
|
|
254
|
+
insert into tasks (id, title, completed, user_id, project_id, server_version)
|
|
255
|
+
values (
|
|
256
|
+
${sql.val(op.row_id)},
|
|
257
|
+
${sql.val(payload.title ?? '')},
|
|
258
|
+
${sql.val(payload.completed ?? 0)},
|
|
259
|
+
${sql.val(ctx.actorId)},
|
|
260
|
+
${sql.val(projectId)},
|
|
261
|
+
${sql.val(1)}
|
|
262
|
+
)
|
|
263
|
+
`.execute(ctx.trx);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const updatedResult = await sql<ProjectScopedTasksRow>`
|
|
267
|
+
select id, title, completed, user_id, project_id, server_version
|
|
268
|
+
from tasks
|
|
269
|
+
where id = ${sql.val(op.row_id)} and user_id = ${sql.val(ctx.actorId)}
|
|
270
|
+
limit 1
|
|
271
|
+
`.execute(ctx.trx);
|
|
272
|
+
const updated = updatedResult.rows[0];
|
|
273
|
+
if (!updated) {
|
|
274
|
+
throw new Error('TASKS_ROW_NOT_FOUND');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const emitted: EmittedChange = {
|
|
278
|
+
table: 'tasks',
|
|
279
|
+
row_id: op.row_id,
|
|
280
|
+
op: 'upsert',
|
|
281
|
+
row_json: {
|
|
282
|
+
id: updated.id,
|
|
283
|
+
title: updated.title,
|
|
284
|
+
completed: updated.completed,
|
|
285
|
+
user_id: updated.user_id,
|
|
286
|
+
project_id: updated.project_id,
|
|
287
|
+
server_version: updated.server_version,
|
|
288
|
+
},
|
|
289
|
+
row_version: updated.server_version,
|
|
290
|
+
scopes: { user_id: ctx.actorId, project_id: updated.project_id },
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
result: { opIndex, status: 'applied' },
|
|
295
|
+
emittedChanges: [emitted],
|
|
296
|
+
};
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|