@spooky-sync/core 0.0.0-canary.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/index.d.ts +590 -0
- package/dist/index.js +3082 -0
- package/package.json +46 -0
- package/src/events/events.test.ts +242 -0
- package/src/events/index.ts +261 -0
- package/src/index.ts +3 -0
- package/src/modules/auth/events/index.ts +18 -0
- package/src/modules/auth/index.ts +267 -0
- package/src/modules/cache/index.ts +241 -0
- package/src/modules/cache/types.ts +19 -0
- package/src/modules/data/data.test.ts +58 -0
- package/src/modules/data/index.ts +777 -0
- package/src/modules/devtools/index.ts +364 -0
- package/src/modules/sync/engine.ts +163 -0
- package/src/modules/sync/events/index.ts +77 -0
- package/src/modules/sync/index.ts +3 -0
- package/src/modules/sync/queue/index.ts +2 -0
- package/src/modules/sync/queue/queue-down.ts +89 -0
- package/src/modules/sync/queue/queue-up.ts +223 -0
- package/src/modules/sync/scheduler.ts +84 -0
- package/src/modules/sync/sync.ts +407 -0
- package/src/modules/sync/utils.test.ts +311 -0
- package/src/modules/sync/utils.ts +171 -0
- package/src/services/database/database.ts +108 -0
- package/src/services/database/events/index.ts +32 -0
- package/src/services/database/index.ts +5 -0
- package/src/services/database/local-migrator.ts +203 -0
- package/src/services/database/local.ts +99 -0
- package/src/services/database/remote.ts +110 -0
- package/src/services/logger/index.ts +118 -0
- package/src/services/persistence/localstorage.ts +26 -0
- package/src/services/persistence/surrealdb.ts +62 -0
- package/src/services/stream-processor/index.ts +364 -0
- package/src/services/stream-processor/stream-processor.test.ts +140 -0
- package/src/services/stream-processor/wasm-types.ts +31 -0
- package/src/spooky.ts +346 -0
- package/src/types.ts +237 -0
- package/src/utils/error-classification.ts +28 -0
- package/src/utils/index.ts +172 -0
- package/src/utils/parser.test.ts +125 -0
- package/src/utils/parser.ts +46 -0
- package/src/utils/surql.ts +182 -0
- package/src/utils/utils.test.ts +152 -0
- package/src/utils/withRetry.test.ts +153 -0
- package/tsconfig.json +14 -0
- package/tsdown.config.ts +9 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { GetTable, SchemaStructure, TableModel, TableNames } from '@spooky-sync/query-builder';
|
|
2
|
+
import { Uuid, RecordId, Duration } from 'surrealdb';
|
|
3
|
+
import { Logger } from '../services/logger/index';
|
|
4
|
+
import { QueryTimeToLive } from '../types';
|
|
5
|
+
|
|
6
|
+
export * from './surql';
|
|
7
|
+
export * from './parser';
|
|
8
|
+
export * from './error-classification';
|
|
9
|
+
|
|
10
|
+
// ==================== RECORDID UTILITIES ====================
|
|
11
|
+
|
|
12
|
+
export const compareRecordIds = (
|
|
13
|
+
a: RecordId<string> | string,
|
|
14
|
+
b: RecordId<string> | string
|
|
15
|
+
): boolean => {
|
|
16
|
+
const nA = a instanceof RecordId ? encodeRecordId(a) : a;
|
|
17
|
+
const nB = b instanceof RecordId ? encodeRecordId(b) : b;
|
|
18
|
+
return nA === nB;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const encodeRecordId = (recordId: RecordId<string>): string => {
|
|
22
|
+
return `${recordId.table.toString()}:${recordId.id}`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const extractIdPart = (id: string | RecordId<string>): string => {
|
|
26
|
+
if (typeof id === 'string') {
|
|
27
|
+
return id.split(':').slice(1).join(':');
|
|
28
|
+
}
|
|
29
|
+
// RecordId.id can be string, number, object, or array
|
|
30
|
+
const idValue = id.id;
|
|
31
|
+
if (typeof idValue === 'string') {
|
|
32
|
+
return idValue;
|
|
33
|
+
}
|
|
34
|
+
// For other types (number, object, array), convert to string
|
|
35
|
+
return String(idValue);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const extractTablePart = (id: string | RecordId<string>): string => {
|
|
39
|
+
if (typeof id === 'string') {
|
|
40
|
+
return id.split(':')[0];
|
|
41
|
+
}
|
|
42
|
+
return id.table.toString();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const parseRecordIdString = (id: string): RecordId<string> => {
|
|
46
|
+
const [table, ...idParts] = id.split(':');
|
|
47
|
+
return new RecordId(table, idParts.join(':'));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function generateId(): string {
|
|
51
|
+
return Uuid.v4().toString().replace(/-/g, '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function generateNewTableId<S extends SchemaStructure, T extends TableNames<S>>(
|
|
55
|
+
tableName: T
|
|
56
|
+
): RecordId {
|
|
57
|
+
return new RecordId(tableName, generateId());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ==================== SCHEMA ENCODING/DECODING ====================
|
|
61
|
+
|
|
62
|
+
export function decodeFromSpooky<S extends SchemaStructure, T extends TableNames<S>>(
|
|
63
|
+
schema: S,
|
|
64
|
+
tableName: T,
|
|
65
|
+
record: TableModel<GetTable<S, T>>
|
|
66
|
+
): TableModel<GetTable<S, T>> {
|
|
67
|
+
const table = schema.tables.find((t) => t.name === tableName);
|
|
68
|
+
if (!table) {
|
|
69
|
+
throw new Error(`Table ${tableName} not found in schema`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const encoded = { ...record } as any;
|
|
73
|
+
|
|
74
|
+
for (const field of Object.keys(table.columns)) {
|
|
75
|
+
const column = table.columns[field] as any;
|
|
76
|
+
const relation = schema.relationships.find((r) => r.from === tableName && r.field === field);
|
|
77
|
+
if ((column.recordId || relation) && encoded[field] != null) {
|
|
78
|
+
if (encoded[field] instanceof RecordId) {
|
|
79
|
+
encoded[field] = `${encoded[field].table.toString()}:${encoded[field].id}`;
|
|
80
|
+
} else if (
|
|
81
|
+
relation &&
|
|
82
|
+
(encoded[field] instanceof Object || encoded[field] instanceof Array)
|
|
83
|
+
) {
|
|
84
|
+
if (Array.isArray(encoded[field])) {
|
|
85
|
+
encoded[field] = encoded[field].map((item) =>
|
|
86
|
+
decodeFromSpooky(schema, relation.to, item)
|
|
87
|
+
);
|
|
88
|
+
} else {
|
|
89
|
+
encoded[field] = decodeFromSpooky(schema, relation.to, encoded[field]);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return encoded as TableModel<GetTable<S, T>>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ==================== TIME/DURATION UTILITIES ====================
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parse duration string or Duration object to milliseconds
|
|
102
|
+
*/
|
|
103
|
+
export function parseDuration(duration: QueryTimeToLive | Duration): number {
|
|
104
|
+
if (duration instanceof Duration) {
|
|
105
|
+
const ms = (duration as any).milliseconds || (duration as any)._milliseconds;
|
|
106
|
+
if (ms) return Number(ms);
|
|
107
|
+
const str = duration.toString();
|
|
108
|
+
if (str !== '[object Object]') return parseDuration(str as any);
|
|
109
|
+
return 600000;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (typeof duration === 'bigint') {
|
|
113
|
+
return Number(duration);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (typeof duration !== 'string') return 600000;
|
|
117
|
+
|
|
118
|
+
const match = duration.match(/^(\d+)([smh])$/);
|
|
119
|
+
if (!match) return 600000;
|
|
120
|
+
const val = parseInt(match[1], 10);
|
|
121
|
+
const unit = match[2];
|
|
122
|
+
switch (unit) {
|
|
123
|
+
case 's':
|
|
124
|
+
return val * 1000;
|
|
125
|
+
case 'h':
|
|
126
|
+
return val * 3600000;
|
|
127
|
+
case 'm':
|
|
128
|
+
default:
|
|
129
|
+
return val * 60000;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ==================== DATABASE UTILITIES ====================
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Helper for retrying DB operations with exponential backoff
|
|
137
|
+
*/
|
|
138
|
+
export async function withRetry<T>(
|
|
139
|
+
logger: Logger,
|
|
140
|
+
operation: () => Promise<T>,
|
|
141
|
+
retries = 3,
|
|
142
|
+
delayMs = 100
|
|
143
|
+
): Promise<T> {
|
|
144
|
+
let lastError;
|
|
145
|
+
for (let i = 0; i < retries; i++) {
|
|
146
|
+
try {
|
|
147
|
+
return await operation();
|
|
148
|
+
} catch (err: any) {
|
|
149
|
+
lastError = err;
|
|
150
|
+
if (
|
|
151
|
+
err?.message?.includes('Can not open transaction') ||
|
|
152
|
+
err?.message?.includes('transaction') ||
|
|
153
|
+
err?.message?.includes('Database is busy')
|
|
154
|
+
) {
|
|
155
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
156
|
+
logger.warn(
|
|
157
|
+
{
|
|
158
|
+
attempt: i + 1,
|
|
159
|
+
retries,
|
|
160
|
+
error: msg,
|
|
161
|
+
Category: 'spooky-client::utils::withRetry',
|
|
162
|
+
},
|
|
163
|
+
'Retrying DB operation'
|
|
164
|
+
);
|
|
165
|
+
await new Promise((res) => setTimeout(res, delayMs * (i + 1)));
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
throw lastError;
|
|
172
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { parseParams, cleanRecord } from './parser';
|
|
4
|
+
|
|
5
|
+
describe('parseParams', () => {
|
|
6
|
+
it('passes through plain values unchanged', () => {
|
|
7
|
+
const schema = {
|
|
8
|
+
name: { type: 'string' as const, optional: false },
|
|
9
|
+
age: { type: 'number' as const, optional: false },
|
|
10
|
+
};
|
|
11
|
+
const result = parseParams(schema, { name: 'Alice', age: 30 });
|
|
12
|
+
expect(result).toEqual({ name: 'Alice', age: 30 });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('converts string to RecordId for recordId columns', () => {
|
|
16
|
+
const schema = {
|
|
17
|
+
owner: { type: 'string' as const, optional: false, recordId: true },
|
|
18
|
+
};
|
|
19
|
+
const result = parseParams(schema, { owner: 'user:123' });
|
|
20
|
+
expect(result.owner).toBeInstanceOf(RecordId);
|
|
21
|
+
expect(result.owner.table.toString()).toBe('user');
|
|
22
|
+
expect(result.owner.id).toBe('123');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('passes through existing RecordId for recordId columns', () => {
|
|
26
|
+
const schema = {
|
|
27
|
+
owner: { type: 'string' as const, optional: false, recordId: true },
|
|
28
|
+
};
|
|
29
|
+
const rid = new RecordId('user', '456');
|
|
30
|
+
const result = parseParams(schema, { owner: rid });
|
|
31
|
+
expect(result.owner).toBe(rid);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('converts string to Date for dateTime columns', () => {
|
|
35
|
+
const schema = {
|
|
36
|
+
createdAt: { type: 'string' as const, optional: false, dateTime: true },
|
|
37
|
+
};
|
|
38
|
+
const result = parseParams(schema, { createdAt: '2024-01-01T00:00:00Z' });
|
|
39
|
+
expect(result.createdAt).toBeInstanceOf(Date);
|
|
40
|
+
expect(result.createdAt.toISOString()).toBe('2024-01-01T00:00:00.000Z');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('converts number (timestamp) to Date for dateTime columns', () => {
|
|
44
|
+
const schema = {
|
|
45
|
+
createdAt: { type: 'string' as const, optional: false, dateTime: true },
|
|
46
|
+
};
|
|
47
|
+
const ts = 1704067200000; // 2024-01-01T00:00:00.000Z
|
|
48
|
+
const result = parseParams(schema, { createdAt: ts });
|
|
49
|
+
expect(result.createdAt).toBeInstanceOf(Date);
|
|
50
|
+
expect(result.createdAt.getTime()).toBe(ts);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('passes through existing Date for dateTime columns', () => {
|
|
54
|
+
const schema = {
|
|
55
|
+
createdAt: { type: 'string' as const, optional: false, dateTime: true },
|
|
56
|
+
};
|
|
57
|
+
const date = new Date('2024-01-01');
|
|
58
|
+
const result = parseParams(schema, { createdAt: date });
|
|
59
|
+
expect(result.createdAt).toBe(date);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('skips undefined values', () => {
|
|
63
|
+
const schema = {
|
|
64
|
+
name: { type: 'string' as const, optional: false },
|
|
65
|
+
age: { type: 'number' as const, optional: true },
|
|
66
|
+
};
|
|
67
|
+
const result = parseParams(schema, { name: 'Alice', age: undefined });
|
|
68
|
+
expect(result).toEqual({ name: 'Alice' });
|
|
69
|
+
expect('age' in result).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('throws on invalid recordId value', () => {
|
|
73
|
+
const schema = {
|
|
74
|
+
owner: { type: 'string' as const, optional: false, recordId: true },
|
|
75
|
+
};
|
|
76
|
+
expect(() => parseParams(schema, { owner: 12345 })).toThrow('Invalid value for owner');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('throws on invalid dateTime value', () => {
|
|
80
|
+
const schema = {
|
|
81
|
+
createdAt: { type: 'string' as const, optional: false, dateTime: true },
|
|
82
|
+
};
|
|
83
|
+
expect(() => parseParams(schema, { createdAt: true })).toThrow(
|
|
84
|
+
'Invalid value for createdAt'
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('cleanRecord', () => {
|
|
90
|
+
const tableSchema = {
|
|
91
|
+
name: { type: 'string' as const, optional: false },
|
|
92
|
+
age: { type: 'number' as const, optional: false },
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
it('keeps schema fields and strips non-schema fields', () => {
|
|
96
|
+
const record = { id: 'user:1', name: 'Alice', age: 30, _internal: true, computed_score: 99 };
|
|
97
|
+
const result = cleanRecord(tableSchema, record);
|
|
98
|
+
expect(result).toEqual({ id: 'user:1', name: 'Alice', age: 30 });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('always preserves id even though it is not in schema', () => {
|
|
102
|
+
const record = { id: 'user:2', extra: 'gone' };
|
|
103
|
+
const result = cleanRecord(tableSchema, record);
|
|
104
|
+
expect(result).toEqual({ id: 'user:2' });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('does not coerce or transform values', () => {
|
|
108
|
+
const date = new Date('2024-01-01');
|
|
109
|
+
const schema = { created: { type: 'string' as const, optional: false, dateTime: true } };
|
|
110
|
+
const record = { id: 'x:1', created: date };
|
|
111
|
+
const result = cleanRecord(schema, record);
|
|
112
|
+
expect(result.created).toBe(date);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('returns only id when record has no schema fields', () => {
|
|
116
|
+
const record = { id: 'user:3', unknown1: 'a', unknown2: 'b' };
|
|
117
|
+
const result = cleanRecord(tableSchema, record);
|
|
118
|
+
expect(result).toEqual({ id: 'user:3' });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('handles empty record', () => {
|
|
122
|
+
const result = cleanRecord(tableSchema, {});
|
|
123
|
+
expect(result).toEqual({});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ColumnSchema, RecordId } from '@spooky-sync/query-builder';
|
|
2
|
+
import { parseRecordIdString } from './index';
|
|
3
|
+
import { DateTime } from 'surrealdb';
|
|
4
|
+
|
|
5
|
+
export function cleanRecord(
|
|
6
|
+
tableSchema: Record<string, ColumnSchema>,
|
|
7
|
+
record: Record<string, any>
|
|
8
|
+
): Record<string, any> {
|
|
9
|
+
const cleaned: Record<string, any> = {};
|
|
10
|
+
for (const [key, value] of Object.entries(record)) {
|
|
11
|
+
if (key === 'id' || key in tableSchema) {
|
|
12
|
+
cleaned[key] = value;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return cleaned;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseParams(
|
|
19
|
+
tableSchema: Record<string, ColumnSchema>,
|
|
20
|
+
params: Record<string, any>
|
|
21
|
+
) {
|
|
22
|
+
const parsedParams: Record<string, any> = {};
|
|
23
|
+
for (const [key, value] of Object.entries(params)) {
|
|
24
|
+
const column = tableSchema[key];
|
|
25
|
+
if (column && value !== undefined) {
|
|
26
|
+
parsedParams[key] = parseValue(key, column, value);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return parsedParams;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseValue(name: string, column: ColumnSchema, value: any) {
|
|
34
|
+
if (column.recordId) {
|
|
35
|
+
if (value instanceof RecordId) return value;
|
|
36
|
+
if (typeof value === 'string') return parseRecordIdString(value);
|
|
37
|
+
throw new Error(`Invalid value for ${name}: ${value}`);
|
|
38
|
+
}
|
|
39
|
+
if (column.dateTime) {
|
|
40
|
+
if (value instanceof Date) return value;
|
|
41
|
+
if (value instanceof DateTime) return value.toDate();
|
|
42
|
+
if (typeof value === 'number' || typeof value === 'string') return new Date(value);
|
|
43
|
+
throw new Error(`Invalid value for ${name}: ${value}`);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { MutationEventType } from '../types';
|
|
2
|
+
|
|
3
|
+
// ==================== TYPES ====================
|
|
4
|
+
|
|
5
|
+
export interface TxQuery {
|
|
6
|
+
readonly __brand: 'TxQuery';
|
|
7
|
+
readonly sql: string;
|
|
8
|
+
readonly statementCount: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SealOptions {
|
|
12
|
+
/** 0-based index of the inner statement to extract. Default: last statement. */
|
|
13
|
+
resultIndex?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SealedQuery<T = void> {
|
|
17
|
+
readonly sql: string;
|
|
18
|
+
readonly extract: (results: unknown[]) => T;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SurqlHelper {
|
|
22
|
+
seal(query: string): string;
|
|
23
|
+
seal<T = void>(query: TxQuery, options?: SealOptions): SealedQuery<T>;
|
|
24
|
+
tx(queries: string[]): TxQuery;
|
|
25
|
+
selectById(idVar: string, returnValues: string[]): string;
|
|
26
|
+
selectByFieldsAnd(
|
|
27
|
+
table: string,
|
|
28
|
+
whereVar: ({ field: string; variable: string } | string)[],
|
|
29
|
+
returnValues: ({ field: string; alias: string } | string)[]
|
|
30
|
+
): string;
|
|
31
|
+
create(idVar: string, dataVar: string): string;
|
|
32
|
+
createSet(
|
|
33
|
+
idVar: string,
|
|
34
|
+
keyDataVars: ({ key: string; variable: string } | { statement: string } | string)[]
|
|
35
|
+
): string;
|
|
36
|
+
upsert(idVar: string, dataVar: string): string;
|
|
37
|
+
updateMerge(idVar: string, dataVar: string): string;
|
|
38
|
+
updateSet(
|
|
39
|
+
idVar: string,
|
|
40
|
+
keyDataVar: ({ key: string; variable: string } | { statement: string } | string)[]
|
|
41
|
+
): string;
|
|
42
|
+
delete(idVar: string): string;
|
|
43
|
+
let(name: string, query: string): string;
|
|
44
|
+
createMutation(
|
|
45
|
+
t: MutationEventType,
|
|
46
|
+
mutationIdVar: string,
|
|
47
|
+
recordIdVar: string,
|
|
48
|
+
dataVar?: string,
|
|
49
|
+
beforeRecordVar?: string
|
|
50
|
+
): string;
|
|
51
|
+
returnObject(entries: { key: string; variable: string }[]): string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ==================== IMPLEMENTATION ====================
|
|
55
|
+
|
|
56
|
+
export const surql: SurqlHelper = {
|
|
57
|
+
seal(query: string | TxQuery, options?: SealOptions): any {
|
|
58
|
+
if (typeof query === 'string') {
|
|
59
|
+
return `${query};`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// TxQuery path
|
|
63
|
+
const txQuery = query;
|
|
64
|
+
const idx = options?.resultIndex ?? txQuery.statementCount - 1;
|
|
65
|
+
const sql = `${txQuery.sql};`;
|
|
66
|
+
return {
|
|
67
|
+
sql,
|
|
68
|
+
extract(results: unknown[]): unknown {
|
|
69
|
+
// +1 to skip the BEGIN null at index 0
|
|
70
|
+
return results[idx + 1];
|
|
71
|
+
},
|
|
72
|
+
} satisfies SealedQuery<unknown>;
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
tx(queries: string[]): TxQuery {
|
|
76
|
+
return {
|
|
77
|
+
__brand: 'TxQuery' as const,
|
|
78
|
+
sql: `BEGIN TRANSACTION;\n${queries.join(';')};\nCOMMIT TRANSACTION`,
|
|
79
|
+
statementCount: queries.length,
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
selectById(idVar: string, returnValues: string[]) {
|
|
84
|
+
return `SELECT ${returnValues.join(',')} FROM ONLY $${idVar}`;
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
selectByFieldsAnd(
|
|
88
|
+
table: string,
|
|
89
|
+
whereVar: ({ field: string; variable: string } | string)[],
|
|
90
|
+
returnValues: ({ field: string; alias: string } | string)[]
|
|
91
|
+
) {
|
|
92
|
+
return `SELECT ${returnValues
|
|
93
|
+
.map((returnValues) =>
|
|
94
|
+
typeof returnValues === 'string'
|
|
95
|
+
? returnValues
|
|
96
|
+
: `${returnValues.field} as ${returnValues.alias}`
|
|
97
|
+
)
|
|
98
|
+
.join(',')} FROM ${table} WHERE ${whereVar
|
|
99
|
+
.map((whereVar) =>
|
|
100
|
+
typeof whereVar === 'string'
|
|
101
|
+
? `${whereVar} = $${whereVar}`
|
|
102
|
+
: `${whereVar.field} = $${whereVar.variable}`
|
|
103
|
+
)
|
|
104
|
+
.join(' AND ')}`;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
create(idVar: string, dataVar: string) {
|
|
108
|
+
return `CREATE ONLY $${idVar} CONTENT $${dataVar}`;
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
createSet(
|
|
112
|
+
idVar: string,
|
|
113
|
+
keyDataVars: ({ key: string; variable: string } | { statement: string } | string)[]
|
|
114
|
+
) {
|
|
115
|
+
return `CREATE ONLY $${idVar} SET ${keyDataVars
|
|
116
|
+
.map((keyDataVar) =>
|
|
117
|
+
typeof keyDataVar === 'string'
|
|
118
|
+
? `${keyDataVar} = $${keyDataVar}`
|
|
119
|
+
: 'statement' in keyDataVar
|
|
120
|
+
? keyDataVar.statement
|
|
121
|
+
: `${keyDataVar.key} = $${keyDataVar.variable}`
|
|
122
|
+
)
|
|
123
|
+
.join(', ')}`;
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
upsert(idVar: string, dataVar: string) {
|
|
127
|
+
return `UPSERT ONLY $${idVar} REPLACE $${dataVar}`;
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
updateMerge(idVar: string, dataVar: string) {
|
|
131
|
+
return `UPDATE ONLY $${idVar} MERGE $${dataVar}`;
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
updateSet(
|
|
135
|
+
idVar: string,
|
|
136
|
+
keyDataVar: ({ key: string; variable: string } | { statement: string } | string)[]
|
|
137
|
+
) {
|
|
138
|
+
return `UPDATE $${idVar} SET ${keyDataVar
|
|
139
|
+
.map((keyDataVar) =>
|
|
140
|
+
typeof keyDataVar === 'string'
|
|
141
|
+
? `${keyDataVar} = $${keyDataVar}`
|
|
142
|
+
: 'statement' in keyDataVar
|
|
143
|
+
? keyDataVar.statement
|
|
144
|
+
: `${keyDataVar.key} = $${keyDataVar.variable}`
|
|
145
|
+
)
|
|
146
|
+
.join(', ')}`;
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
delete(idVar: string) {
|
|
150
|
+
return `DELETE $${idVar}`;
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
let(name: string, query: string) {
|
|
154
|
+
return `LET $${name} = (${query})`;
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
createMutation(
|
|
158
|
+
t: MutationEventType,
|
|
159
|
+
mutationIdVar: string,
|
|
160
|
+
recordIdVar: string,
|
|
161
|
+
dataVar?: string,
|
|
162
|
+
beforeRecordVar?: string
|
|
163
|
+
) {
|
|
164
|
+
switch (t) {
|
|
165
|
+
case 'create':
|
|
166
|
+
return `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}`;
|
|
167
|
+
case 'update': {
|
|
168
|
+
let stmt = `CREATE ONLY $${mutationIdVar} SET mutationType = 'update', recordId = $${recordIdVar}, data = $${dataVar}`;
|
|
169
|
+
if (beforeRecordVar) {
|
|
170
|
+
stmt += `, beforeRecord = $${beforeRecordVar}`;
|
|
171
|
+
}
|
|
172
|
+
return stmt;
|
|
173
|
+
}
|
|
174
|
+
case 'delete':
|
|
175
|
+
return `CREATE ONLY $${mutationIdVar} SET mutationType = 'delete', recordId = $${recordIdVar}`;
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
returnObject(entries: { key: string; variable: string }[]) {
|
|
180
|
+
return `RETURN {${entries.map(({ key, variable }) => `${key}: $${variable}`).join(',')}}`;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import {
|
|
4
|
+
compareRecordIds,
|
|
5
|
+
encodeRecordId,
|
|
6
|
+
extractIdPart,
|
|
7
|
+
extractTablePart,
|
|
8
|
+
parseRecordIdString,
|
|
9
|
+
generateId,
|
|
10
|
+
parseDuration,
|
|
11
|
+
} from './index';
|
|
12
|
+
|
|
13
|
+
describe('compareRecordIds', () => {
|
|
14
|
+
it('returns true for equal strings', () => {
|
|
15
|
+
expect(compareRecordIds('user:123', 'user:123')).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns false for different strings', () => {
|
|
19
|
+
expect(compareRecordIds('user:123', 'user:456')).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns true for equal RecordIds', () => {
|
|
23
|
+
const a = new RecordId('user', '123');
|
|
24
|
+
const b = new RecordId('user', '123');
|
|
25
|
+
expect(compareRecordIds(a, b)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns false for different RecordIds', () => {
|
|
29
|
+
const a = new RecordId('user', '123');
|
|
30
|
+
const b = new RecordId('user', '456');
|
|
31
|
+
expect(compareRecordIds(a, b)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns true for RecordId matching equivalent string', () => {
|
|
35
|
+
const rid = new RecordId('user', '123');
|
|
36
|
+
expect(compareRecordIds(rid, 'user:123')).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns true for string matching equivalent RecordId', () => {
|
|
40
|
+
const rid = new RecordId('user', '123');
|
|
41
|
+
expect(compareRecordIds('user:123', rid)).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns false for mismatched RecordId and string', () => {
|
|
45
|
+
const rid = new RecordId('user', '123');
|
|
46
|
+
expect(compareRecordIds(rid, 'post:123')).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('encodeRecordId', () => {
|
|
51
|
+
it('encodes a RecordId to table:id format', () => {
|
|
52
|
+
const rid = new RecordId('user', 'abc');
|
|
53
|
+
expect(encodeRecordId(rid)).toBe('user:abc');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('encodes a RecordId with numeric-like id', () => {
|
|
57
|
+
const rid = new RecordId('post', '42');
|
|
58
|
+
expect(encodeRecordId(rid)).toBe('post:42');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('extractIdPart', () => {
|
|
63
|
+
it('extracts id from a string', () => {
|
|
64
|
+
expect(extractIdPart('user:123')).toBe('123');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('extracts id from a string with colons in the id', () => {
|
|
68
|
+
expect(extractIdPart('table:some:complex:id')).toBe('some:complex:id');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('extracts string id from a RecordId', () => {
|
|
72
|
+
const rid = new RecordId('user', 'abc');
|
|
73
|
+
expect(extractIdPart(rid)).toBe('abc');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('extracts numeric id from a RecordId as string', () => {
|
|
77
|
+
const rid = new RecordId('user', 42 as any);
|
|
78
|
+
expect(extractIdPart(rid)).toBe('42');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('extractTablePart', () => {
|
|
83
|
+
it('extracts table from a string', () => {
|
|
84
|
+
expect(extractTablePart('user:123')).toBe('user');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('extracts table from a string with colons in the id', () => {
|
|
88
|
+
expect(extractTablePart('table:some:complex:id')).toBe('table');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('extracts table from a RecordId', () => {
|
|
92
|
+
const rid = new RecordId('post', 'abc');
|
|
93
|
+
expect(extractTablePart(rid)).toBe('post');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('parseRecordIdString', () => {
|
|
98
|
+
it('parses standard table:id format', () => {
|
|
99
|
+
const rid = parseRecordIdString('user:123');
|
|
100
|
+
expect(rid).toBeInstanceOf(RecordId);
|
|
101
|
+
expect(rid.table.toString()).toBe('user');
|
|
102
|
+
expect(rid.id).toBe('123');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('parses id containing colons', () => {
|
|
106
|
+
const rid = parseRecordIdString('table:part1:part2');
|
|
107
|
+
expect(rid.table.toString()).toBe('table');
|
|
108
|
+
expect(rid.id).toBe('part1:part2');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('generateId', () => {
|
|
113
|
+
it('returns a 32-character hex string', () => {
|
|
114
|
+
const id = generateId();
|
|
115
|
+
expect(id).toMatch(/^[0-9a-f]{32}$/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('generates unique ids across calls', () => {
|
|
119
|
+
const ids = new Set(Array.from({ length: 100 }, () => generateId()));
|
|
120
|
+
expect(ids.size).toBe(100);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('parseDuration', () => {
|
|
125
|
+
it('parses seconds', () => {
|
|
126
|
+
expect(parseDuration('30s')).toBe(30000);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('parses minutes', () => {
|
|
130
|
+
expect(parseDuration('5m')).toBe(300000);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('parses hours', () => {
|
|
134
|
+
expect(parseDuration('1h')).toBe(3600000);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('returns default for invalid string', () => {
|
|
138
|
+
expect(parseDuration('invalid' as any)).toBe(600000);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('returns default for unknown unit', () => {
|
|
142
|
+
expect(parseDuration('10d' as any)).toBe(600000);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns default for non-string non-bigint input', () => {
|
|
146
|
+
expect(parseDuration(undefined as any)).toBe(600000);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('handles bigint input', () => {
|
|
150
|
+
expect(parseDuration(5000n as any)).toBe(5000);
|
|
151
|
+
});
|
|
152
|
+
});
|