@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,227 @@
1
+ import type { DwnDatabaseType, KeyValues } from './types.js';
2
+ import type { EventLog, Filter, PaginationCursor } from '@enbox/dwn-sdk-js';
3
+
4
+ import { Dialect } from './dialect/dialect.js';
5
+ import { filterSelectQuery } from './utils/filter.js';
6
+ import { Kysely, Transaction } from 'kysely';
7
+ import { executeWithRetryIfDatabaseIsLocked } from './utils/transaction.js';
8
+ import { extractTagsAndSanitizeIndexes } from './utils/sanitize.js';
9
+ import { TagTables } from './utils/tags.js';
10
+
11
+ export class EventLogSql implements EventLog {
12
+ #dialect: Dialect;
13
+ #db: Kysely<DwnDatabaseType> | null = null;
14
+ #tags: TagTables;
15
+
16
+ constructor(dialect: Dialect) {
17
+ this.#dialect = dialect;
18
+ this.#tags = new TagTables(dialect, 'eventLogMessages');
19
+ }
20
+
21
+ async open(): Promise<void> {
22
+ if (this.#db) {
23
+ return;
24
+ }
25
+
26
+ this.#db = new Kysely<DwnDatabaseType>({ dialect: this.#dialect });
27
+ let createTable = this.#db.schema
28
+ .createTable('eventLogMessages')
29
+ .ifNotExists()
30
+ .addColumn('tenant', 'varchar(255)', (col) => col.notNull())
31
+ .addColumn('messageCid', 'varchar(60)', (col) => col.notNull())
32
+ .addColumn('interface', 'varchar(20)')
33
+ .addColumn('method', 'varchar(20)')
34
+ .addColumn('recordId', 'varchar(60)')
35
+ .addColumn('entryId','varchar(60)')
36
+ .addColumn('parentId', 'varchar(60)')
37
+ .addColumn('protocol', 'varchar(200)')
38
+ .addColumn('protocolPath', 'varchar(200)')
39
+ .addColumn('contextId', 'varchar(500)')
40
+ .addColumn('schema', 'varchar(200)')
41
+ .addColumn('author', 'varchar(255)')
42
+ .addColumn('recipient', 'varchar(255)')
43
+ .addColumn('messageTimestamp', 'varchar(30)')
44
+ .addColumn('dateCreated', 'varchar(30)')
45
+ .addColumn('datePublished', 'varchar(30)')
46
+ .addColumn('isLatestBaseState', 'boolean')
47
+ .addColumn('published', 'boolean')
48
+ .addColumn('prune', 'boolean')
49
+ .addColumn('dataFormat', 'varchar(30)')
50
+ .addColumn('dataCid', 'varchar(60)')
51
+ .addColumn('dataSize', 'integer')
52
+ // .addColumn('encodedData', 'text') // intentionally kept commented out code to show the only difference to `messageStoreMessages` table
53
+ .addColumn('attester', 'text')
54
+ .addColumn('permissionGrantId', 'varchar(60)')
55
+ .addColumn('latest', 'text'); // TODO: obsolete, remove once `dwn-sdk-js` is updated
56
+
57
+ let createRecordsTagsTable = this.#db.schema
58
+ .createTable('eventLogRecordsTags')
59
+ .ifNotExists()
60
+ .addColumn('tag', 'text', (col) => col.notNull())
61
+ .addColumn('valueString', 'text')
62
+ .addColumn('valueNumber', 'decimal');
63
+ // Add columns that have dialect-specific constraints
64
+ createTable = this.#dialect.addAutoIncrementingColumn(createTable, 'watermark', (col) => col.primaryKey());
65
+ createRecordsTagsTable = this.#dialect.addAutoIncrementingColumn(createRecordsTagsTable, 'id', (col) => col.primaryKey());
66
+ createRecordsTagsTable = this.#dialect.addReferencedColumn(createRecordsTagsTable, 'eventLogRecordsTags', 'eventWatermark', 'integer', 'eventLogMessages', 'watermark', 'cascade');
67
+
68
+ await createTable.execute();
69
+ await createRecordsTagsTable.execute();
70
+ }
71
+
72
+ async close(): Promise<void> {
73
+ await this.#db?.destroy();
74
+ this.#db = null;
75
+ }
76
+
77
+ async append(
78
+ tenant: string,
79
+ messageCid: string,
80
+ indexes: Record<string, string | boolean | number>
81
+ ): Promise<void> {
82
+ if (!this.#db) {
83
+ throw new Error(
84
+ 'Connection to database not open. Call `open` before using `append`.'
85
+ );
86
+ }
87
+
88
+ // we execute the insert in a transaction as we are making multiple inserts into multiple tables.
89
+ // if any of these inserts would throw, the whole transaction would be rolled back.
90
+ // otherwise it is committed.
91
+ const putEventOperation = this.constructPutEventOperation({ tenant, messageCid, indexes });
92
+ await executeWithRetryIfDatabaseIsLocked(this.#db, putEventOperation);
93
+ }
94
+
95
+ /**
96
+ * Constructs a transactional operation to insert an event into the database.
97
+ */
98
+ private constructPutEventOperation(queryOptions: {
99
+ tenant: string;
100
+ messageCid: string;
101
+ indexes: KeyValues;
102
+ }): (tx: Transaction<DwnDatabaseType>) => Promise<void> {
103
+ const { tenant, messageCid, indexes } = queryOptions;
104
+
105
+ // we extract the tag indexes into their own object to be inserted separately.
106
+ // we also sanitize the indexes to convert any `boolean` values to `text` representations.
107
+ const { indexes: appendIndexes, tags } = extractTagsAndSanitizeIndexes(indexes);
108
+
109
+ return async (tx) => {
110
+
111
+ const eventIndexValues = {
112
+ tenant,
113
+ messageCid,
114
+ ...appendIndexes,
115
+ };
116
+
117
+ // we use the dialect-specific `insertThenReturnId` in order to be able to extract the `insertId`
118
+ const result = await this.#dialect
119
+ .insertThenReturnId(tx, 'eventLogMessages', eventIndexValues, 'watermark as insertId')
120
+ .executeTakeFirstOrThrow();
121
+
122
+ // if tags exist, we execute those within the transaction associating them with the `insertId`.
123
+ if (Object.keys(tags).length > 0) {
124
+ await this.#tags.executeTagsInsert(result.insertId, tags, tx);
125
+ }
126
+ };
127
+ }
128
+
129
+ async getEvents(
130
+ tenant: string,
131
+ cursor?: PaginationCursor
132
+ ): Promise<{events: string[], cursor?: PaginationCursor }> {
133
+
134
+ // get events is simply a query without any filters. gets all events beyond the cursor.
135
+ return this.queryEvents(tenant, [], cursor);
136
+ }
137
+
138
+ async queryEvents(
139
+ tenant: string,
140
+ filters: Filter[],
141
+ cursor?: PaginationCursor
142
+ ): Promise<{events: string[], cursor?: PaginationCursor }> {
143
+ if (!this.#db) {
144
+ throw new Error(
145
+ 'Connection to database not open. Call `open` before using `queryEvents`.'
146
+ );
147
+ }
148
+
149
+ let query = this.#db
150
+ .selectFrom('eventLogMessages')
151
+ .leftJoin('eventLogRecordsTags', 'eventLogRecordsTags.eventWatermark', 'eventLogMessages.watermark')
152
+ .select('messageCid')
153
+ .distinct()
154
+ .select('watermark')
155
+ .where('tenant', '=', tenant);
156
+
157
+ if (filters.length > 0) {
158
+ // filter sanitization takes place within `filterSelectQuery`
159
+ query = filterSelectQuery(filters, query);
160
+ }
161
+
162
+ if(cursor !== undefined) {
163
+ // eventLogMessages in the sql store uses the watermark cursor value which is a number in SQL
164
+ // if not we will return empty results
165
+ const cursorValue = cursor.value as number;
166
+ const cursorMessageCid = cursor.messageCid;
167
+
168
+ query = query.where(({ eb, refTuple, tuple }) => {
169
+ // https://kysely-org.github.io/kysely-apidoc/interfaces/ExpressionBuilder.html#refTuple
170
+ return eb(refTuple('watermark', 'messageCid'), '>', tuple(cursorValue, cursorMessageCid));
171
+ });
172
+ }
173
+
174
+ query = query.orderBy('watermark', 'asc').orderBy('messageCid', 'asc');
175
+
176
+ const events: string[] = [];
177
+ // we always return a cursor with the event log query, so we set the return cursor to the properties of the last item.
178
+ let returnCursor: PaginationCursor | undefined;
179
+ if (this.#dialect.isStreamingSupported) {
180
+ for await (let { messageCid, watermark: value } of query.stream()) {
181
+ events.push(messageCid);
182
+ returnCursor = { messageCid, value };
183
+ }
184
+ } else {
185
+ const results = await query.execute();
186
+ for (let { messageCid, watermark: value } of results) {
187
+ events.push(messageCid);
188
+ returnCursor = { messageCid, value };
189
+ }
190
+ }
191
+
192
+ return { events, cursor: returnCursor };
193
+ }
194
+
195
+ async deleteEventsByCid(
196
+ tenant: string,
197
+ messageCids: Array<string>
198
+ ): Promise<void> {
199
+ if (!this.#db) {
200
+ throw new Error(
201
+ 'Connection to database not open. Call `open` before using `deleteEventsByCid`.'
202
+ );
203
+ }
204
+
205
+ if (messageCids.length === 0) {
206
+ return;
207
+ }
208
+
209
+ await this.#db
210
+ .deleteFrom('eventLogMessages')
211
+ .where('tenant', '=', tenant)
212
+ .where('messageCid', 'in', messageCids)
213
+ .execute();
214
+ }
215
+
216
+ async clear(): Promise<void> {
217
+ if (!this.#db) {
218
+ throw new Error(
219
+ 'Connection to database not open. Call `open` before using `clear`.'
220
+ );
221
+ }
222
+
223
+ await this.#db
224
+ .deleteFrom('eventLogMessages')
225
+ .execute();
226
+ }
227
+ }
package/src/main.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from './dialect/dialect.js';
2
+ export * from './dialect/mysql-dialect.js';
3
+ export * from './dialect/postgres-dialect.js';
4
+ export * from './dialect/sqlite-dialect.js';
5
+ export * from './data-store-sql.js';
6
+ export * from './event-log-sql.js';
7
+ export * from './message-store-sql.js';
8
+ export * from './resumable-task-store-sql.js';
@@ -0,0 +1,440 @@
1
+ import {
2
+ DwnInterfaceName,
3
+ DwnMethodName,
4
+ executeUnlessAborted,
5
+ Filter,
6
+ GenericMessage,
7
+ MessageStore,
8
+ MessageStoreOptions,
9
+ MessageSort,
10
+ Pagination,
11
+ SortDirection,
12
+ PaginationCursor,
13
+ } from '@enbox/dwn-sdk-js';
14
+
15
+ import { Kysely, Transaction } from 'kysely';
16
+ import { DwnDatabaseType, KeyValues } from './types.js';
17
+ import * as block from 'multiformats/block';
18
+ import * as cbor from '@ipld/dag-cbor';
19
+ import { Dialect } from './dialect/dialect.js';
20
+ import { executeWithRetryIfDatabaseIsLocked } from './utils/transaction.js';
21
+ import { extractTagsAndSanitizeIndexes } from './utils/sanitize.js';
22
+ import { filterSelectQuery } from './utils/filter.js';
23
+ import { sha256 } from 'multiformats/hashes/sha2';
24
+ import { TagTables } from './utils/tags.js';
25
+
26
+
27
+ export class MessageStoreSql implements MessageStore {
28
+ #dialect: Dialect;
29
+ #tags: TagTables;
30
+ #db: Kysely<DwnDatabaseType> | null = null;
31
+
32
+ constructor(dialect: Dialect) {
33
+ this.#dialect = dialect;
34
+ this.#tags = new TagTables(dialect, 'messageStoreMessages');
35
+ }
36
+
37
+ async open(): Promise<void> {
38
+ if (this.#db) {
39
+ return;
40
+ }
41
+
42
+ this.#db = new Kysely<DwnDatabaseType>({ dialect: this.#dialect });
43
+
44
+ // create messages table if it does not exist
45
+ const messagesTableName = 'messageStoreMessages';
46
+ const messagesTableExists = await this.#dialect.hasTable(this.#db, messagesTableName);
47
+ if (!messagesTableExists) {
48
+ let createMessagesTable = this.#db.schema
49
+ .createTable(messagesTableName)
50
+ .ifNotExists()
51
+ .addColumn('tenant', 'varchar(255)', (col) => col.notNull())
52
+ .addColumn('messageCid', 'varchar(60)', (col) => col.notNull())
53
+ .addColumn('interface', 'varchar(20)')
54
+ .addColumn('method', 'varchar(20)')
55
+ .addColumn('recordId', 'varchar(60)')
56
+ .addColumn('entryId','varchar(60)')
57
+ .addColumn('parentId', 'varchar(60)')
58
+ .addColumn('protocol', 'varchar(200)')
59
+ .addColumn('protocolPath', 'varchar(200)')
60
+ .addColumn('contextId', 'varchar(500)')
61
+ .addColumn('schema', 'varchar(200)')
62
+ .addColumn('author', 'varchar(255)')
63
+ .addColumn('recipient', 'varchar(255)')
64
+ .addColumn('messageTimestamp', 'varchar(30)')
65
+ .addColumn('dateCreated', 'varchar(30)')
66
+ .addColumn('datePublished', 'varchar(30)')
67
+ .addColumn('isLatestBaseState', 'boolean')
68
+ .addColumn('published', 'boolean')
69
+ .addColumn('prune', 'boolean')
70
+ .addColumn('dataFormat', 'varchar(30)')
71
+ .addColumn('dataCid', 'varchar(60)')
72
+ .addColumn('dataSize', 'integer')
73
+ .addColumn('encodedData', 'text') // we optionally store encoded data if it is below a threshold
74
+ .addColumn('attester', 'text')
75
+ .addColumn('permissionGrantId', 'varchar(60)')
76
+ .addColumn('latest', 'text'); // TODO: obsolete, remove once `dwn-sdk-js` tests are updated
77
+
78
+ // Add columns that have dialect-specific constraints
79
+ createMessagesTable = this.#dialect.addAutoIncrementingColumn(createMessagesTable, 'id', (col) => col.primaryKey());
80
+ createMessagesTable = this.#dialect.addBlobColumn(createMessagesTable, 'encodedMessageBytes', (col) => col.notNull());
81
+ await createMessagesTable.execute();
82
+
83
+ // add indexes to the table
84
+ await this.createIndexes(this.#db, messagesTableName, [
85
+ ['tenant'], // baseline protection to prevent full table scans across all tenants
86
+ ['tenant', 'recordId'], // multiple uses, notably heavily depended by record chain construction for protocol authorization
87
+ ['tenant', 'parentId'], // used to walk down hierarchy of records, use cases include purging of records
88
+ ['tenant', 'protocol', 'published', 'messageTimestamp'], // index used for basically every external query.
89
+ ['tenant', 'interface'], // mainly for fast fetch of ProtocolsConfigure for authorization, not needed if protocol was a DWN Record
90
+ ['tenant', 'contextId', 'messageTimestamp'], // expected to be used for common query pattern
91
+ ['tenant', 'permissionGrantId'], // for deleting grant-authorized messages though pending https://github.com/TBD54566975/dwn-sdk-js/issues/716
92
+ // other potential indexes
93
+ // ['tenant', 'author'],
94
+ // ['tenant', 'recipient'],
95
+ // ['tenant', 'schema', 'dataFormat'],
96
+ // ['tenant', 'dateCreated'],
97
+ // ['tenant', 'datePublished'],
98
+ // ['tenant', 'messageCid'],
99
+ // ['tenant', 'protocolPath'],
100
+ ]);
101
+ }
102
+
103
+ // create tags table
104
+ const tagsTableName = 'messageStoreRecordsTags';
105
+ const tagsTableExists = await this.#dialect.hasTable(this.#db, tagsTableName);
106
+ if (!tagsTableExists) {
107
+ let createRecordsTagsTable = this.#db.schema
108
+ .createTable(tagsTableName)
109
+ .ifNotExists()
110
+ .addColumn('tag', 'varchar(30)', (col) => col.notNull())
111
+ .addColumn('valueString', 'varchar(200)')
112
+ .addColumn('valueNumber', 'decimal');
113
+
114
+ // Add columns that have dialect-specific constraints
115
+ const foreignMessageInsertId = 'messageInsertId';
116
+ createRecordsTagsTable = this.#dialect.addAutoIncrementingColumn(createRecordsTagsTable, 'id', (col) => col.primaryKey());
117
+ createRecordsTagsTable = this.#dialect.addReferencedColumn(createRecordsTagsTable, tagsTableName, foreignMessageInsertId, 'integer', 'messageStoreMessages', 'id', 'cascade');
118
+ await createRecordsTagsTable.execute();
119
+
120
+ // add indexes to the table
121
+ await this.createIndexes(this.#db, tagsTableName, [
122
+ [foreignMessageInsertId],
123
+ ['tag', 'valueString'],
124
+ ['tag', 'valueNumber']
125
+ ]);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Creates indexes on the given table.
131
+ * @param tableName The name of the table to create the indexes on.
132
+ * @param indexes Each inner array represents a single index and contains the column names to be indexed as a composite index.
133
+ * If the inner array contains only one element, it will be treated as a single column index.
134
+ */
135
+ async createIndexes<T>(database: Kysely<T>, tableName: string, indexes: string[][]): Promise<void> {
136
+ for (const columnNames of indexes) {
137
+ const indexName = 'index_' + columnNames.join('_'); // e.g. index_tenant_protocol
138
+ await database.schema
139
+ .createIndex(indexName)
140
+ // .ifNotExists() // intentionally kept commented out code to show that it is not supported by all dialects (ie. MySQL)
141
+ .on(tableName)
142
+ .columns(columnNames)
143
+ .execute();
144
+ }
145
+ }
146
+
147
+ async close(): Promise<void> {
148
+ await this.#db?.destroy();
149
+ this.#db = null;
150
+ }
151
+
152
+ async put(
153
+ tenant: string,
154
+ message: GenericMessage,
155
+ indexes: KeyValues,
156
+ options?: MessageStoreOptions
157
+ ): Promise<void> {
158
+ if (!this.#db) {
159
+ throw new Error(
160
+ 'Connection to database not open. Call `open` before using `put`.'
161
+ );
162
+ }
163
+
164
+ options?.signal?.throwIfAborted();
165
+
166
+ // gets the encoded data and removes it from the message
167
+ // we remove it from the message as it would cause the `encodedMessageBytes` to be greater than the
168
+ // maximum bytes allowed by SQL
169
+ const getEncodedData = (message: GenericMessage): { message: GenericMessage, encodedData: string|null} => {
170
+ let encodedData: string|null = null;
171
+ if (message.descriptor.interface === DwnInterfaceName.Records && message.descriptor.method === DwnMethodName.Write) {
172
+ const data = (message as any).encodedData as string|undefined;
173
+ if(data) {
174
+ delete (message as any).encodedData;
175
+ encodedData = data;
176
+ }
177
+ }
178
+ return { message, encodedData };
179
+ };
180
+
181
+ const { message: messageToProcess, encodedData} = getEncodedData(message);
182
+
183
+ const encodedMessageBlock = await executeUnlessAborted(
184
+ block.encode({ value: messageToProcess, codec: cbor, hasher: sha256}),
185
+ options?.signal
186
+ );
187
+
188
+ const messageCid = encodedMessageBlock.cid.toString();
189
+ const encodedMessageBytes = Buffer.from(encodedMessageBlock.bytes);
190
+
191
+ // we execute the insert in a transaction as we are making multiple inserts into multiple tables.
192
+ // if any of these inserts would throw, the whole transaction would be rolled back.
193
+ // otherwise it is committed.
194
+ const putMessageOperation = this.constructPutMessageOperation({ tenant, messageCid, encodedMessageBytes, encodedData, indexes });
195
+ await executeWithRetryIfDatabaseIsLocked(this.#db, putMessageOperation);
196
+ }
197
+
198
+ /**
199
+ * Constructs the transactional operation to insert the given message into the database.
200
+ */
201
+ private constructPutMessageOperation(queryOptions: {
202
+ tenant: string;
203
+ messageCid: string;
204
+ encodedMessageBytes: Buffer;
205
+ encodedData: string | null;
206
+ indexes: KeyValues;
207
+ }): (tx: Transaction<DwnDatabaseType>) => Promise<void> {
208
+ const { tenant, messageCid, encodedMessageBytes, encodedData, indexes } = queryOptions;
209
+
210
+ // we extract the tag indexes into their own object to be inserted separately.
211
+ // we also sanitize the indexes to convert any `boolean` values to `text` representations.
212
+ const { indexes: putIndexes, tags } = extractTagsAndSanitizeIndexes(indexes);
213
+
214
+ return async (tx) => {
215
+
216
+ const messageIndexValues = {
217
+ tenant,
218
+ messageCid,
219
+ encodedMessageBytes,
220
+ encodedData,
221
+ ...putIndexes
222
+ };
223
+
224
+ // we use the dialect-specific `insertThenReturnId` in order to be able to extract the `insertId`
225
+ const result = await this.#dialect
226
+ .insertThenReturnId(tx, 'messageStoreMessages', messageIndexValues, 'id as insertId')
227
+ .executeTakeFirstOrThrow();
228
+
229
+ // if tags exist, we execute those within the transaction associating them with the `insertId`.
230
+ if (Object.keys(tags).length > 0) {
231
+ await this.#tags.executeTagsInsert(result.insertId, tags, tx);
232
+ }
233
+
234
+ };
235
+ }
236
+
237
+ async get(
238
+ tenant: string,
239
+ cid: string,
240
+ options?: MessageStoreOptions
241
+ ): Promise<GenericMessage | undefined> {
242
+ if (!this.#db) {
243
+ throw new Error(
244
+ 'Connection to database not open. Call `open` before using `get`.'
245
+ );
246
+ }
247
+
248
+ options?.signal?.throwIfAborted();
249
+
250
+ const result = await executeUnlessAborted(
251
+ this.#db
252
+ .selectFrom('messageStoreMessages')
253
+ .selectAll()
254
+ .where('tenant', '=', tenant)
255
+ .where('messageCid', '=', cid)
256
+ .executeTakeFirst(),
257
+ options?.signal
258
+ );
259
+
260
+ if (!result) {
261
+ return undefined;
262
+ }
263
+
264
+ return this.parseEncodedMessage(result.encodedMessageBytes, result.encodedData, options);
265
+ }
266
+
267
+ async query(
268
+ tenant: string,
269
+ filters: Filter[],
270
+ messageSort?: MessageSort,
271
+ pagination?: Pagination,
272
+ options?: MessageStoreOptions
273
+ ): Promise<{ messages: GenericMessage[], cursor?: PaginationCursor}> {
274
+ if (!this.#db) {
275
+ throw new Error(
276
+ 'Connection to database not open. Call `open` before using `query`.'
277
+ );
278
+ }
279
+
280
+ options?.signal?.throwIfAborted();
281
+
282
+ // extract sort property and direction from the supplied messageSort
283
+ const { property: sortProperty, direction: sortDirection } = this.extractSortProperties(messageSort);
284
+
285
+ let query = this.#db
286
+ .selectFrom('messageStoreMessages')
287
+ .leftJoin('messageStoreRecordsTags', 'messageStoreRecordsTags.messageInsertId', 'messageStoreMessages.id')
288
+ .select('messageCid')
289
+ .distinct()
290
+ .select([
291
+ 'encodedMessageBytes',
292
+ 'encodedData',
293
+ sortProperty,
294
+ ])
295
+ .where('tenant', '=', tenant);
296
+
297
+ // filter sanitization takes place within `filterSelectQuery`
298
+ query = filterSelectQuery(filters, query);
299
+
300
+ if(pagination?.cursor !== undefined) {
301
+ // currently the sort property is explicitly either `dateCreated` | `messageTimestamp` | `datePublished` which are all strings
302
+ // TODO: https://github.com/TBD54566975/dwn-sdk-js/issues/664 to handle the edge case
303
+ const cursorValue = pagination.cursor.value as string;
304
+ const cursorMessageId = pagination.cursor.messageCid;
305
+
306
+ query = query.where(({ eb, refTuple, tuple }) => {
307
+ const direction = sortDirection === SortDirection.Ascending ? '>' : '<';
308
+ // https://kysely-org.github.io/kysely-apidoc/interfaces/ExpressionBuilder.html#refTuple
309
+ return eb(refTuple(sortProperty, 'messageCid'), direction, tuple(cursorValue, cursorMessageId));
310
+ });
311
+ }
312
+
313
+ const orderDirection = sortDirection === SortDirection.Ascending ? 'asc' : 'desc';
314
+ // sorting by the provided sort property, the tiebreak is always in ascending order regardless of sort
315
+ query = query
316
+ .orderBy(sortProperty, orderDirection)
317
+ .orderBy('messageCid', orderDirection);
318
+
319
+ if (pagination?.limit !== undefined && pagination?.limit > 0) {
320
+ // we query for one additional record to decide if we return a pagination cursor or not.
321
+ query = query.limit(pagination.limit + 1);
322
+ }
323
+
324
+ const results = await executeUnlessAborted(
325
+ query.execute(),
326
+ options?.signal
327
+ );
328
+
329
+ // prunes the additional requested message, if it exists, and adds a cursor to the results.
330
+ // also parses the encoded message for each of the returned results.
331
+ return this.processPaginationResults(results, sortProperty, pagination?.limit, options);
332
+ }
333
+
334
+ async delete(
335
+ tenant: string,
336
+ cid: string,
337
+ options?: MessageStoreOptions
338
+ ): Promise<void> {
339
+ if (!this.#db) {
340
+ throw new Error(
341
+ 'Connection to database not open. Call `open` before using `delete`.'
342
+ );
343
+ }
344
+
345
+ options?.signal?.throwIfAborted();
346
+
347
+ await executeUnlessAborted(
348
+ this.#db
349
+ .deleteFrom('messageStoreMessages')
350
+ .where('tenant', '=', tenant)
351
+ .where('messageCid', '=', cid)
352
+ .execute(),
353
+ options?.signal
354
+ );
355
+ }
356
+
357
+ async clear(): Promise<void> {
358
+ if (!this.#db) {
359
+ throw new Error(
360
+ 'Connection to database not open. Call `open` before using `clear`.'
361
+ );
362
+ }
363
+
364
+ await this.#db
365
+ .deleteFrom('messageStoreMessages')
366
+ .execute();
367
+ }
368
+
369
+ private async parseEncodedMessage(
370
+ encodedMessageBytes: Uint8Array,
371
+ encodedData: string | null | undefined,
372
+ options?: MessageStoreOptions
373
+ ): Promise<GenericMessage> {
374
+ options?.signal?.throwIfAborted();
375
+
376
+ const decodedBlock = await block.decode({
377
+ bytes : encodedMessageBytes,
378
+ codec : cbor,
379
+ hasher : sha256
380
+ });
381
+
382
+ const message = decodedBlock.value as GenericMessage;
383
+ // If encodedData is stored within the MessageStore we include it in the response.
384
+ // We store encodedData when the data is below a certain threshold.
385
+ // https://github.com/TBD54566975/dwn-sdk-js/pull/456
386
+ if (message !== undefined && encodedData !== undefined && encodedData !== null) {
387
+ (message as any).encodedData = encodedData;
388
+ }
389
+ return message;
390
+ }
391
+
392
+ /**
393
+ * Processes the paginated query results.
394
+ * Builds a pagination cursor if there are additional messages to paginate.
395
+ * Accepts more messages than the limit, as we query for additional records to check if we should paginate.
396
+ *
397
+ * @param messages a list of messages, potentially larger than the provided limit.
398
+ * @param limit the maximum number of messages to be returned
399
+ *
400
+ * @returns the pruned message results and an optional pagination cursor
401
+ */
402
+ private async processPaginationResults(
403
+ results: any[],
404
+ sortProperty: string,
405
+ limit?: number,
406
+ options?: MessageStoreOptions,
407
+ ): Promise<{ messages: GenericMessage[], cursor?: PaginationCursor}> {
408
+ // we queried for one additional message to determine if there are any additional messages beyond the limit
409
+ // we now check if the returned results are greater than the limit, if so we pluck the last item out of the result set
410
+ // the cursor is always the last item in the *returned* result so we use the last item in the remaining result set to build a cursor
411
+ let cursor: PaginationCursor | undefined;
412
+ if (limit !== undefined && results.length > limit) {
413
+ results = results.slice(0, limit);
414
+ const lastMessage = results.at(-1);
415
+ const cursorValue = lastMessage[sortProperty];
416
+ cursor = { messageCid: lastMessage.messageCid, value: cursorValue };
417
+ }
418
+
419
+ // extracts the full encoded message from the stored blob for each result item.
420
+ const messages: Promise<GenericMessage>[] = results.map(r => this.parseEncodedMessage(r.encodedMessageBytes, r.encodedData, options));
421
+ return { messages: await Promise.all(messages), cursor };
422
+ }
423
+
424
+ /**
425
+ * Extracts the appropriate sort property and direction given a MessageSort object.
426
+ */
427
+ private extractSortProperties(
428
+ messageSort?: MessageSort
429
+ ):{ property: 'dateCreated' | 'datePublished' | 'messageTimestamp', direction: SortDirection } {
430
+ if(messageSort?.dateCreated !== undefined) {
431
+ return { property: 'dateCreated', direction: messageSort.dateCreated };
432
+ } else if(messageSort?.datePublished !== undefined) {
433
+ return { property: 'datePublished', direction: messageSort.datePublished };
434
+ } else if (messageSort?.messageTimestamp !== undefined) {
435
+ return { property: 'messageTimestamp', direction: messageSort.messageTimestamp };
436
+ } else {
437
+ return { property: 'messageTimestamp', direction: SortDirection.Ascending };
438
+ }
439
+ }
440
+ }