@enbox/dwn-sql-store 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +178 -0
  3. package/dist/cjs/main.js +3784 -0
  4. package/dist/cjs/package.json +1 -0
  5. package/dist/esm/src/data-store-sql.js +103 -0
  6. package/dist/esm/src/data-store-sql.js.map +1 -0
  7. package/dist/esm/src/dialect/dialect.js +2 -0
  8. package/dist/esm/src/dialect/dialect.js.map +1 -0
  9. package/dist/esm/src/dialect/mysql-dialect.js +40 -0
  10. package/dist/esm/src/dialect/mysql-dialect.js.map +1 -0
  11. package/dist/esm/src/dialect/postgres-dialect.js +26 -0
  12. package/dist/esm/src/dialect/postgres-dialect.js.map +1 -0
  13. package/dist/esm/src/dialect/sqlite-dialect.js +33 -0
  14. package/dist/esm/src/dialect/sqlite-dialect.js.map +1 -0
  15. package/dist/esm/src/event-log-sql.js +169 -0
  16. package/dist/esm/src/event-log-sql.js.map +1 -0
  17. package/dist/esm/src/main.js +9 -0
  18. package/dist/esm/src/main.js.map +1 -0
  19. package/dist/esm/src/message-store-sql.js +317 -0
  20. package/dist/esm/src/message-store-sql.js.map +1 -0
  21. package/dist/esm/src/resumable-task-store-sql.js +141 -0
  22. package/dist/esm/src/resumable-task-store-sql.js.map +1 -0
  23. package/dist/esm/src/types.js +2 -0
  24. package/dist/esm/src/types.js.map +1 -0
  25. package/dist/esm/src/utils/filter.js +125 -0
  26. package/dist/esm/src/utils/filter.js.map +1 -0
  27. package/dist/esm/src/utils/sanitize.js +92 -0
  28. package/dist/esm/src/utils/sanitize.js.map +1 -0
  29. package/dist/esm/src/utils/tags.js +38 -0
  30. package/dist/esm/src/utils/tags.js.map +1 -0
  31. package/dist/esm/src/utils/transaction.js +25 -0
  32. package/dist/esm/src/utils/transaction.js.map +1 -0
  33. package/dist/types/src/data-store-sql.d.ts +14 -0
  34. package/dist/types/src/data-store-sql.d.ts.map +1 -0
  35. package/dist/types/src/dialect/dialect.d.ts +40 -0
  36. package/dist/types/src/dialect/dialect.d.ts.map +1 -0
  37. package/dist/types/src/dialect/mysql-dialect.d.ts +18 -0
  38. package/dist/types/src/dialect/mysql-dialect.d.ts.map +1 -0
  39. package/dist/types/src/dialect/postgres-dialect.d.ts +12 -0
  40. package/dist/types/src/dialect/postgres-dialect.d.ts.map +1 -0
  41. package/dist/types/src/dialect/sqlite-dialect.d.ts +12 -0
  42. package/dist/types/src/dialect/sqlite-dialect.d.ts.map +1 -0
  43. package/dist/types/src/event-log-sql.d.ts +24 -0
  44. package/dist/types/src/event-log-sql.d.ts.map +1 -0
  45. package/dist/types/src/main.d.ts +9 -0
  46. package/dist/types/src/main.d.ts.map +1 -0
  47. package/dist/types/src/message-store-sql.d.ts +46 -0
  48. package/dist/types/src/message-store-sql.d.ts.map +1 -0
  49. package/dist/types/src/resumable-task-store-sql.d.ts +16 -0
  50. package/dist/types/src/resumable-task-store-sql.d.ts.map +1 -0
  51. package/dist/types/src/types.d.ts +100 -0
  52. package/dist/types/src/types.d.ts.map +1 -0
  53. package/dist/types/src/utils/filter.d.ts +13 -0
  54. package/dist/types/src/utils/filter.d.ts.map +1 -0
  55. package/dist/types/src/utils/sanitize.d.ts +28 -0
  56. package/dist/types/src/utils/sanitize.d.ts.map +1 -0
  57. package/dist/types/src/utils/tags.d.ts +20 -0
  58. package/dist/types/src/utils/tags.d.ts.map +1 -0
  59. package/dist/types/src/utils/transaction.d.ts +7 -0
  60. package/dist/types/src/utils/transaction.d.ts.map +1 -0
  61. package/package.json +91 -0
  62. package/src/data-store-sql.ts +148 -0
  63. package/src/dialect/dialect.ts +77 -0
  64. package/src/dialect/mysql-dialect.ts +86 -0
  65. package/src/dialect/postgres-dialect.ts +66 -0
  66. package/src/dialect/sqlite-dialect.ts +72 -0
  67. package/src/event-log-sql.ts +227 -0
  68. package/src/main.ts +8 -0
  69. package/src/message-store-sql.ts +440 -0
  70. package/src/resumable-task-store-sql.ts +174 -0
  71. package/src/types.ts +109 -0
  72. package/src/utils/filter.ts +136 -0
  73. package/src/utils/sanitize.ts +117 -0
  74. package/src/utils/tags.ts +46 -0
  75. package/src/utils/transaction.ts +28 -0
@@ -0,0 +1,174 @@
1
+ import { DwnDatabaseType } from './types.js';
2
+ import { Dialect } from './dialect/dialect.js';
3
+ import { executeWithRetryIfDatabaseIsLocked } from './utils/transaction.js';
4
+ import { Kysely } from 'kysely';
5
+ import { Cid, ManagedResumableTask, ResumableTaskStore } from '@enbox/dwn-sdk-js';
6
+
7
+ export class ResumableTaskStoreSql implements ResumableTaskStore {
8
+ private static readonly taskTimeoutInSeconds = 60;
9
+
10
+ #dialect: Dialect;
11
+ #db: Kysely<DwnDatabaseType> | null = null;
12
+
13
+ constructor(dialect: Dialect) {
14
+ this.#dialect = dialect;
15
+ }
16
+
17
+ async open(): Promise<void> {
18
+ if (this.#db) {
19
+ return;
20
+ }
21
+
22
+ this.#db = new Kysely<DwnDatabaseType>({ dialect: this.#dialect });
23
+
24
+ // if table already exists, there is no more things todo
25
+ const tableName = 'resumableTasks';
26
+ const tableExists = await this.#dialect.hasTable(this.#db, tableName);
27
+ if (tableExists) {
28
+ return;
29
+ }
30
+
31
+ // else create the table and corresponding indexes
32
+
33
+ let table = this.#db.schema
34
+ .createTable(tableName)
35
+ .ifNotExists() // kept to show supported by all dialects in contrast to ifNotExists() below, though not needed due to hasTable() check above
36
+ .addColumn('id', 'varchar(255)', (col) => col.primaryKey())
37
+ .addColumn('task', 'text')
38
+ .addColumn('timeout', 'bigint')
39
+ .addColumn('retryCount', 'integer');
40
+
41
+ await table.execute();
42
+
43
+ await this.#db.schema
44
+ .createIndex('index_timeout')
45
+ // .ifNotExists() // intentionally kept commented out code to show that it is not supported by all dialects (ie. MySQL)
46
+ .on('resumableTasks')
47
+ .column('timeout')
48
+ .execute();
49
+ }
50
+
51
+ async close(): Promise<void> {
52
+ await this.#db?.destroy();
53
+ this.#db = null;
54
+ }
55
+
56
+ async register(task: any, timeoutInSeconds: number): Promise<ManagedResumableTask> {
57
+ if (!this.#db) {
58
+ throw new Error('Connection to database not open. Call `open` before using `register`.');
59
+ }
60
+
61
+ const id = await Cid.computeCid(task);
62
+ const timeout = Date.now() + timeoutInSeconds * 1000;
63
+ const taskString = JSON.stringify(task);
64
+ const retryCount = 0;
65
+ const taskEntryInDatabase: ManagedResumableTask = { id, task: taskString, timeout, retryCount };
66
+ await this.#db.insertInto('resumableTasks').values(taskEntryInDatabase).execute();
67
+
68
+ return {
69
+ id,
70
+ task,
71
+ retryCount,
72
+ timeout,
73
+ };
74
+ }
75
+
76
+ async grab(count: number): Promise<ManagedResumableTask[]> {
77
+ if (!this.#db) {
78
+ throw new Error('Connection to database not open. Call `open` before using `grab`.');
79
+ }
80
+
81
+ const now = Date.now();
82
+ const newTimeout = now + (ResumableTaskStoreSql.taskTimeoutInSeconds * 1000);
83
+
84
+ let tasks: DwnDatabaseType['resumableTasks'][] = [];
85
+
86
+ const operation = async (transaction) => {
87
+ tasks = await transaction
88
+ .selectFrom('resumableTasks')
89
+ .selectAll()
90
+ .where('timeout', '<=', now)
91
+ .limit(count)
92
+ .execute();
93
+
94
+ if (tasks.length > 0) {
95
+ const ids = tasks.map((task) => task.id);
96
+ await transaction
97
+ .updateTable('resumableTasks')
98
+ .set({ timeout: newTimeout })
99
+ .where((eb) => eb('id', 'in', ids))
100
+ .execute();
101
+ }
102
+ };
103
+
104
+ await executeWithRetryIfDatabaseIsLocked(this.#db, operation);
105
+
106
+ const tasksToReturn = tasks.map((task) => {
107
+ return {
108
+ id : task.id,
109
+ task : JSON.parse(task.task),
110
+ retryCount : task.retryCount,
111
+ timeout : task.timeout,
112
+ };
113
+ });
114
+
115
+ return tasksToReturn;
116
+ }
117
+
118
+ async read(taskId: string): Promise<ManagedResumableTask | undefined> {
119
+ if (!this.#db) {
120
+ throw new Error('Connection to database not open. Call `open` before using `read`.');
121
+ }
122
+
123
+ const task = await this.#db
124
+ .selectFrom('resumableTasks')
125
+ .selectAll()
126
+ .where('id', '=', taskId)
127
+ .executeTakeFirst();
128
+
129
+ if (task !== undefined) {
130
+ // NOTE: special handling ONLY for PostgreSQL:
131
+ // Even though PostgreSQL stores `bigint` as a 64 bit number, the `pg` library we depend on returns it as a string, hence the conversion.
132
+ if (typeof task.timeout !== 'number') {
133
+ task.timeout = parseInt(task.timeout, 10);
134
+ }
135
+ }
136
+
137
+ return task;
138
+ }
139
+
140
+ async extend(taskId: string, timeoutInSeconds: number): Promise<void> {
141
+ if (!this.#db) {
142
+ throw new Error('Connection to database not open. Call `open` before using `extend`.');
143
+ }
144
+
145
+ const timeout = Date.now() + (timeoutInSeconds * 1000);
146
+
147
+ await this.#db
148
+ .updateTable('resumableTasks')
149
+ .set({ timeout })
150
+ .where('id', '=', taskId)
151
+ .execute();
152
+ }
153
+
154
+ async delete(taskId: string): Promise<void> {
155
+ if (!this.#db) {
156
+ throw new Error('Connection to database not open. Call `open` before using `delete`.');
157
+ }
158
+
159
+ await this.#db
160
+ .deleteFrom('resumableTasks')
161
+ .where('id', '=', taskId)
162
+ .execute();
163
+ }
164
+
165
+ async clear(): Promise<void> {
166
+ if (!this.#db) {
167
+ throw new Error('Connection to database not open. Call `open` before using `clear`.');
168
+ }
169
+
170
+ await this.#db
171
+ .deleteFrom('resumableTasks')
172
+ .execute();
173
+ }
174
+ }
package/src/types.ts ADDED
@@ -0,0 +1,109 @@
1
+ import type { Generated } from 'kysely';
2
+
3
+ export type KeyValues = { [key:string]: string | number | boolean | string[] | number[] };
4
+
5
+ type EventLogTable = {
6
+ watermark: Generated<number>;
7
+ tenant: string;
8
+ messageCid: string;
9
+
10
+ // "indexes" start
11
+ interface: string | null;
12
+ method: string | null;
13
+ schema: string | null;
14
+ dataCid: string | null;
15
+ dataSize: number | null;
16
+ dateCreated: string | null;
17
+ messageTimestamp: string | null;
18
+ dataFormat: string | null;
19
+ isLatestBaseState: boolean | null;
20
+ published: boolean | null;
21
+ author: string | null;
22
+ recordId: string | null;
23
+ entryId: string | null;
24
+ datePublished: string | null;
25
+ latest: string | null;
26
+ protocol: string | null;
27
+ permissionsRequestId: string | null;
28
+ attester: string | null;
29
+ protocolPath: string | null;
30
+ recipient: string | null;
31
+ contextId: string | null;
32
+ parentId: string | null;
33
+ permissionGrantId: string | null;
34
+ prune: boolean | null;
35
+ // "indexes" end
36
+ }
37
+
38
+ type MessageStoreTable = {
39
+ id: Generated<number>;
40
+ tenant: string;
41
+ messageCid: string;
42
+ encodedMessageBytes: Uint8Array;
43
+ encodedData: string | null;
44
+ // "indexes" start
45
+ interface: string | null;
46
+ method: string | null;
47
+ schema: string | null;
48
+ dataCid: string | null;
49
+ dataSize: number | null;
50
+ dateCreated: string | null;
51
+ messageTimestamp: string | null;
52
+ dataFormat: string | null;
53
+ isLatestBaseState: boolean | null;
54
+ published: boolean | null;
55
+ author: string | null;
56
+ recordId: string | null;
57
+ entryId: string | null;
58
+ datePublished: string | null;
59
+ protocol: string | null;
60
+ permissionsRequestId: string | null;
61
+ attester: string | null;
62
+ protocolPath: string | null;
63
+ recipient: string | null;
64
+ contextId: string | null;
65
+ parentId: string | null;
66
+ permissionGrantId: string | null;
67
+ prune: boolean | null;
68
+ // "indexes" end
69
+ }
70
+
71
+ type MessageStoreRecordsTagsTable = {
72
+ id: Generated<number>;
73
+ tag: string;
74
+ messageInsertId: number;
75
+ valueString: string | null;
76
+ valueNumber: number | null;
77
+ };
78
+
79
+ type EventLogRecordsTagsTable = {
80
+ id: Generated<number>;
81
+ tag: string;
82
+ eventWatermark: number;
83
+ valueString: string | null;
84
+ valueNumber: number | null;
85
+ };
86
+
87
+ type DataStoreTable = {
88
+ id: Generated<number>;
89
+ tenant: string;
90
+ recordId: string;
91
+ dataCid: string;
92
+ data: Uint8Array;
93
+ }
94
+
95
+ type ResumableTaskTable = {
96
+ id: string;
97
+ task: string;
98
+ timeout: number;
99
+ retryCount: number;
100
+ }
101
+
102
+ export type DwnDatabaseType = {
103
+ eventLogMessages: EventLogTable;
104
+ eventLogRecordsTags: EventLogRecordsTagsTable;
105
+ messageStoreMessages: MessageStoreTable;
106
+ messageStoreRecordsTags: MessageStoreRecordsTagsTable;
107
+ dataStore: DataStoreTable;
108
+ resumableTasks: ResumableTaskTable;
109
+ }
@@ -0,0 +1,136 @@
1
+ import { Filter } from '@enbox/dwn-sdk-js';
2
+ import { DynamicModule, ExpressionBuilder, OperandExpression, SelectQueryBuilder, SqlBool } from 'kysely';
3
+ import { sanitizeFiltersAndSeparateTags, sanitizedValue } from './sanitize.js';
4
+ import { DwnDatabaseType } from '../types.js';
5
+
6
+ /**
7
+ * Takes multiple Filters and returns a single query.
8
+ * Each filter is evaluated as an OR operation.
9
+ *
10
+ * @param filters Array of filters to be evaluated as OR operations
11
+ * @param query the incoming QueryBuilder.
12
+ * @returns The modified QueryBuilder respecting the provided filters.
13
+ */
14
+ export function filterSelectQuery<DB = DwnDatabaseType, TB extends keyof DB = keyof DB, O = unknown>(
15
+ filters: Filter[],
16
+ query: SelectQueryBuilder<DB, TB, O>
17
+ ): SelectQueryBuilder<DB, TB, O> {
18
+ const sanitizedFilters = sanitizeFiltersAndSeparateTags(filters);
19
+
20
+ return query.where((eb) =>
21
+ // evaluate the filters as an OR expression.
22
+ eb.or(sanitizedFilters.map(({ filter, tags }) => {
23
+ // evaluate each filter + tags tuple as an AND expression.
24
+ const andOperands: OperandExpression<SqlBool>[] = [];
25
+
26
+ processFilter(eb, andOperands, filter);
27
+ processTags(eb, andOperands, tags);
28
+
29
+ return eb.and(andOperands);
30
+ }))
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Processes each property in the non-tags filter as an AND operand and adds it to the `andOperands` array.
36
+ * If a property has an array of values it will treat it as a OneOf (IN) within the overall AND query.
37
+ *
38
+ * @param eb The ExpressionBuilder from the query.
39
+ * @param andOperands The array of AND operands to append to.
40
+ * @param filter The filter to be evaluated.
41
+ */
42
+ function processFilter<DB = DwnDatabaseType, TB extends keyof DB = keyof DB>(
43
+ eb: ExpressionBuilder<DB, TB>,
44
+ andOperands: OperandExpression<SqlBool>[],
45
+ filter: Filter
46
+ ): void {
47
+ for (let property in filter) {
48
+ const value = filter[property];
49
+ const column = new DynamicModule().ref(property);
50
+ if (Array.isArray(value)) { // OneOfFilter
51
+ andOperands.push(eb(column, 'in', value));
52
+ } else if (typeof value === 'object') { // RangeFilter
53
+ if (value.gt) {
54
+ andOperands.push(eb(column, '>', sanitizedValue(value.gt)));
55
+ }
56
+ if (value.gte) {
57
+ andOperands.push(eb(column, '>=', sanitizedValue(value.gte)));
58
+ }
59
+ if (value.lt) {
60
+ andOperands.push(eb(column, '<', sanitizedValue(value.lt)));
61
+ }
62
+ if (value.lte) {
63
+ andOperands.push(eb(column, '<=', sanitizedValue(value.lte)));
64
+ }
65
+ } else { // EqualFilter
66
+ andOperands.push(eb(column, '=', sanitizedValue(value)));
67
+ }
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Processes each property in the tags filter as an AND operand and adds it to the `andOperands` array.
73
+ * If a property has an array of values it will treat it as a OneOf (IN) within the overall AND query.
74
+ *
75
+ * @param eb The ExpressionBuilder from the query.
76
+ * @param andOperands The array of AND operands to append to.
77
+ * @param tag The tags filter to be evaluated.
78
+ */
79
+ function processTags<DB = DwnDatabaseType, TB extends keyof DB = keyof DB>(
80
+ eb: ExpressionBuilder<DB, TB>,
81
+ andOperands: OperandExpression<SqlBool>[],
82
+ tags: Filter
83
+ ): void {
84
+
85
+ const tagColumn = new DynamicModule().ref('tag');
86
+ const valueNumber = new DynamicModule().ref('valueNumber');
87
+ const valueString = new DynamicModule().ref('valueString');
88
+
89
+ // process each tag and add it to the andOperands from the rest of the filters
90
+ for (let property in tags) {
91
+ andOperands.push(eb(tagColumn, '=', property));
92
+ const value = tags[property];
93
+ if (Array.isArray(value)) { // OneOfFilter
94
+ if (value.some(val => typeof val === 'number')) {
95
+ andOperands.push(eb(valueNumber, 'in', value));
96
+ } else {
97
+ andOperands.push(eb(valueString, 'in', value.map(v => String(v))));
98
+ }
99
+ } else if (typeof value === 'object') { // RangeFilter
100
+ if (value.gt) {
101
+ if (typeof value.gt === 'number') {
102
+ andOperands.push(eb(valueNumber, '>', value.gt));
103
+ } else {
104
+ andOperands.push(eb(valueString, '>', String(value.gt)));
105
+ }
106
+ }
107
+ if (value.gte) {
108
+ if (typeof value.gte === 'number') {
109
+ andOperands.push(eb(valueNumber, '>=', value.gte));
110
+ } else {
111
+ andOperands.push(eb(valueString, '>=', String(value.gte)));
112
+ }
113
+ }
114
+ if (value.lt) {
115
+ if (typeof value.lt === 'number') {
116
+ andOperands.push(eb(valueNumber, '<', value.lt));
117
+ } else {
118
+ andOperands.push(eb(valueString, '<', String(value.lt)));
119
+ }
120
+ }
121
+ if (value.lte) {
122
+ if (typeof value.lte === 'number') {
123
+ andOperands.push(eb(valueNumber, '<=', value.lte));
124
+ } else {
125
+ andOperands.push(eb(valueString, '<=', String(value.lte)));
126
+ }
127
+ }
128
+ } else { // EqualFilter
129
+ if (typeof value === 'number') {
130
+ andOperands.push(eb(valueNumber, '=', value));
131
+ } else {
132
+ andOperands.push(eb(valueString, '=', String(value)));
133
+ }
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,117 @@
1
+ import { Filter } from '@enbox/dwn-sdk-js';
2
+ import { KeyValues } from '../types.js';
3
+
4
+ export function extractTagsAndSanitizeIndexes(records: KeyValues): {
5
+ tags: KeyValues;
6
+ indexes: KeyValues;
7
+ } {
8
+
9
+ const tags = {};
10
+ const indexes = { ...records };
11
+
12
+ sanitizeIndexes(indexes);
13
+
14
+ // tag values are prefixed with 'tag.', we extract them to be inserted separately into the tags reference tables.
15
+ // we delete them from the `indexes` object so they are not included in the main insert.
16
+ for (let key in indexes) {
17
+ if (key.startsWith('tag.')) {
18
+ let value = indexes[key];
19
+ delete indexes[key];
20
+ tags[key.slice(4)] = value;
21
+ }
22
+ }
23
+
24
+ return { tags, indexes };
25
+ }
26
+
27
+ export function sanitizeIndexes(records: KeyValues) {
28
+ for (let key in records) {
29
+ let value = records[key];
30
+ if (Array.isArray(value)) {
31
+ const sanitizedValues: any[] = [];
32
+ for (const valueItem of value) {
33
+ sanitizedValues.push(sanitizedValue(valueItem));
34
+ }
35
+ records[key] = sanitizedValues;
36
+ continue;
37
+ }
38
+
39
+ records[key] = sanitizedValue(value);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Sanitizes the given value into a string or number.
45
+ * NOTE: sqlite3 we use does not support inserting boolean values, so we convert them to a number.
46
+ */
47
+ export function sanitizedValue(value: string | number | boolean): string | number {
48
+ switch (typeof value) {
49
+ case 'boolean':
50
+ return value ? 1 : 0;
51
+ default:
52
+ return value;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Sanitizes the filters and separates tags from non-tags filter.
58
+ */
59
+ export function sanitizeFiltersAndSeparateTags(filters: Filter[]): {
60
+ tags: Filter;
61
+ filter: Filter;
62
+ }[] {
63
+
64
+ const extractedFilters: { tags: Filter, filter: Filter }[] = [];
65
+
66
+ for (const filter of filters) {
67
+ const tagFilter = {};
68
+ const nonTagFilter = {};
69
+
70
+ // tag values are prefixed with 'tag.', we extract them to be queried separately in the tags tables.
71
+ for (let key in filter) {
72
+ const value = sanitizeFilterValue(filter[key]);
73
+
74
+ if (key.startsWith('tag.')) {
75
+ tagFilter[key.slice(4)] = value;
76
+ } else {
77
+ nonTagFilter[key] = value;
78
+ }
79
+ }
80
+
81
+ extractedFilters.push({
82
+ tags : tagFilter,
83
+ filter : nonTagFilter,
84
+ });
85
+ }
86
+
87
+ return extractedFilters;
88
+ }
89
+
90
+ // we sanitize the filter value for a number representation of the boolean
91
+
92
+ /**
93
+ * Sanitizes the given filter value to align with the value conversions done during insertions/updates.
94
+ * NOTE: sqlite3 we use does not support inserting boolean values,
95
+ * so we convert them to a number during insertions/updates, as a result we need to align the filter values in queries.
96
+ */
97
+ // TODO: export filter types from `dwn-sdk-js`
98
+ export function sanitizeFilterValue(value: any): any {
99
+ switch (typeof value) {
100
+ case 'boolean':
101
+ return value ? 1 : 0;
102
+ default:
103
+ return value;
104
+ }
105
+ }
106
+
107
+ export function sanitizeFilters(filters: Filter[]) {
108
+ filters.forEach(sanitizeFilter);
109
+ }
110
+
111
+ export function sanitizeFilter(filter: Filter): Filter {
112
+ for (let key in filter) {
113
+ let value = filter[key];
114
+ filter[key] = sanitizeFilterValue(value);
115
+ }
116
+ return filter;
117
+ }
@@ -0,0 +1,46 @@
1
+ import { Transaction } from 'kysely';
2
+
3
+ import type { DwnDatabaseType, KeyValues } from '../types.js';
4
+
5
+ import { Dialect } from '../dialect/dialect.js';
6
+ import { sanitizedValue } from './sanitize.js';
7
+
8
+ /**
9
+ * Helper class to manage adding indexes for `RecordsWrite` messages which contain `tags`.
10
+ */
11
+ export class TagTables {
12
+
13
+ /**
14
+ * @param dialect the target dialect, necessary for returning the `insertId`
15
+ * @param table the DB Table in order to index the tags and values in the correct tables. Choice between `messageStoreMessages` and `eventLogMessages`
16
+ */
17
+ constructor(private dialect: Dialect, private table: 'messageStoreMessages' | 'eventLogMessages'){}
18
+
19
+ /**
20
+ * Inserts the given tags associated with the given foreign `insertId`.
21
+ */
22
+ async executeTagsInsert(
23
+ foreignInsertId: number,
24
+ tags: KeyValues,
25
+ tx: Transaction<DwnDatabaseType>,
26
+ ):Promise<void> {
27
+ const tagTable = this.table === 'messageStoreMessages' ? 'messageStoreRecordsTags' : 'eventLogRecordsTags';
28
+ const foreignKeyReference = tagTable === 'messageStoreRecordsTags' ? { messageInsertId: foreignInsertId } : { eventWatermark: foreignInsertId };
29
+
30
+ for (const tag in tags) {
31
+ const tagValues = tags[tag];
32
+ const values = Array.isArray(tagValues) ? tagValues : [ tagValues ];
33
+
34
+ for(const value of values) {
35
+ const tagInsertValue = sanitizedValue(value);
36
+ const insertValues = {
37
+ tag,
38
+ valueNumber : typeof tagInsertValue === 'number' ? tagInsertValue : null,
39
+ valueString : typeof tagInsertValue === 'string' ? tagInsertValue : null,
40
+ ...foreignKeyReference,
41
+ };
42
+ await this.dialect.insertThenReturnId(tx, tagTable, insertValues, 'id as insertId').executeTakeFirstOrThrow();
43
+ }
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,28 @@
1
+ import { DwnDatabaseType } from '../types.js';
2
+ import { Kysely, Transaction } from 'kysely';
3
+
4
+ /**
5
+ * Executes the provided transactional operation with retry if the database is locked.
6
+ */
7
+ export async function executeWithRetryIfDatabaseIsLocked(
8
+ database: Kysely<DwnDatabaseType>,
9
+ operation: (transaction: Transaction<DwnDatabaseType>) => Promise<void>
10
+ ): Promise<void>{
11
+ let retryCount = 0;
12
+ let success = false;
13
+ while (!success) {
14
+ try {
15
+ await database.transaction().execute(operation);
16
+ success = true;
17
+ } catch (error) {
18
+ // if error is "database is locked", we retry the transaction
19
+ // this mainly happens when multiple transactions are trying to access the database at the same time in SQLite implementation.
20
+ if (error.code === 'SQLITE_BUSY') {
21
+ retryCount++;
22
+ console.log(`Database is locked when attempting SQL operation, retrying #${retryCount}...`);
23
+ } else {
24
+ throw error;
25
+ }
26
+ }
27
+ }
28
+ }