-
Notifications
You must be signed in to change notification settings - Fork 592
Implemented one-off query support for TypeScript SDK #2960
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 4 commits
5572ec0
fa46dce
8020dac
f2aed43
67853e1
cbb3a9d
3cb722f
07cb776
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -30,6 +30,7 @@ import type { Event } from './event.ts'; | |
import { | ||
type ErrorContextInterface, | ||
type EventContextInterface, | ||
type QueryEventContextInterface, | ||
type ReducerEventContextInterface, | ||
type SubscriptionEventContextInterface, | ||
} from './event_context.ts'; | ||
|
@@ -39,6 +40,7 @@ import type { Identity } from './identity.ts'; | |
import type { | ||
IdentityTokenMessage, | ||
Message, | ||
QueryResolvedMessage, | ||
SubscribeAppliedMessage, | ||
UnsubscribeAppliedMessage, | ||
} from './message_types.ts'; | ||
|
@@ -53,6 +55,12 @@ import { | |
import { deepEqual, toPascalCase } from './utils.ts'; | ||
import { WebsocketDecompressAdapter } from './websocket_decompress_adapter.ts'; | ||
import type { WebsocketTestAdapter } from './websocket_test_adapter.ts'; | ||
import { | ||
QueryBuilderImpl, | ||
QueryHandleImpl, | ||
QueryManager, | ||
type QueryEvent, | ||
} from './query_builder_impl.ts'; | ||
import { | ||
SubscriptionBuilderImpl, | ||
SubscriptionHandleImpl, | ||
|
@@ -94,6 +102,9 @@ export type { | |
export type ConnectionEvent = 'connect' | 'disconnect' | 'connectError'; | ||
export type CallReducerFlags = 'FullUpdate' | 'NoSuccessNotify'; | ||
|
||
type QueryEventCallback = ( | ||
ctx: QueryEventContextInterface | ||
) => void; | ||
type ReducerEventCallback<ReducerArgs extends any[] = any[]> = ( | ||
ctx: ReducerEventContextInterface, | ||
...args: ReducerArgs | ||
|
@@ -177,6 +188,7 @@ export class DbConnectionImpl< | |
#onApplied?: SubscriptionEventCallback; | ||
#remoteModule: RemoteModule; | ||
#messageQueue = Promise.resolve(); | ||
#queryManager = new QueryManager(); | ||
#subscriptionManager = new SubscriptionManager(); | ||
|
||
// These fields are not part of the public API, but in a pinch you | ||
|
@@ -259,6 +271,29 @@ export class DbConnectionImpl< | |
return queryId; | ||
}; | ||
|
||
queryBuilder = (): QueryBuilderImpl => { | ||
return new QueryBuilderImpl(this); | ||
}; | ||
|
||
registerQuery( | ||
handle: QueryHandleImpl<DBView, Reducers, SetReducerFlags>, | ||
handleEmitter: EventEmitter<QueryEvent, QueryEventCallback>, | ||
querySql: string, | ||
): number { | ||
const queryId = this.#getNextQueryId(); | ||
this.#queryManager.queries.set(queryId, { | ||
handle, | ||
emitter: handleEmitter, | ||
}); | ||
this.#sendMessage( | ||
ClientMessage.OneOffQuery({ | ||
queryString: querySql, | ||
messageId: new Uint8Array([queryId]), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will be a problem after
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure, thanks for spotting it! I added a single-byte counter as a placeholder and totally forgot to widen it. |
||
}) | ||
); | ||
return queryId; | ||
} | ||
|
||
// NOTE: This is very important!!! This is the actual function that | ||
// gets called when you call `connection.subscriptionBuilder()`. | ||
// The `subscriptionBuilder` function which is generated, just shadows | ||
|
@@ -303,49 +338,49 @@ export class DbConnectionImpl< | |
); | ||
} | ||
|
||
#parseRowList( | ||
type: 'insert' | 'delete', | ||
tableName: string, | ||
rowList: BsatnRowList | ||
): Operation[] { | ||
const buffer = rowList.rowsData; | ||
const reader = new BinaryReader(buffer); | ||
const rows: Operation[] = []; | ||
const rowType = this.#remoteModule.tables[tableName]!.rowType; | ||
const primaryKeyInfo = | ||
this.#remoteModule.tables[tableName]!.primaryKeyInfo; | ||
while (reader.offset < buffer.length + buffer.byteOffset) { | ||
const initialOffset = reader.offset; | ||
const row = rowType.deserialize(reader); | ||
let rowId: ComparablePrimitive | undefined = undefined; | ||
if (primaryKeyInfo !== undefined) { | ||
rowId = primaryKeyInfo.colType.intoMapKey( | ||
row[primaryKeyInfo.colName] | ||
); | ||
} else { | ||
// Get a view of the bytes for this row. | ||
const rowBytes = buffer.subarray( | ||
initialOffset - buffer.byteOffset, | ||
reader.offset - buffer.byteOffset | ||
); | ||
// Convert it to a base64 string, so we can use it as a map key. | ||
const asBase64 = fromByteArray(rowBytes); | ||
rowId = asBase64; | ||
} | ||
|
||
rows.push({ | ||
type, | ||
rowId, | ||
row, | ||
}); | ||
} | ||
return rows; | ||
} | ||
|
||
// This function is async because we decompress the message async | ||
async #processParsedMessage( | ||
message: ServerMessage | ||
): Promise<Message | undefined> { | ||
const parseRowList = ( | ||
type: 'insert' | 'delete', | ||
tableName: string, | ||
rowList: BsatnRowList | ||
): Operation[] => { | ||
const buffer = rowList.rowsData; | ||
const reader = new BinaryReader(buffer); | ||
const rows: Operation[] = []; | ||
const rowType = this.#remoteModule.tables[tableName]!.rowType; | ||
const primaryKeyInfo = | ||
this.#remoteModule.tables[tableName]!.primaryKeyInfo; | ||
while (reader.offset < buffer.length + buffer.byteOffset) { | ||
const initialOffset = reader.offset; | ||
const row = rowType.deserialize(reader); | ||
let rowId: ComparablePrimitive | undefined = undefined; | ||
if (primaryKeyInfo !== undefined) { | ||
rowId = primaryKeyInfo.colType.intoMapKey( | ||
row[primaryKeyInfo.colName] | ||
); | ||
} else { | ||
// Get a view of the bytes for this row. | ||
const rowBytes = buffer.subarray( | ||
initialOffset - buffer.byteOffset, | ||
reader.offset - buffer.byteOffset | ||
); | ||
// Convert it to a base64 string, so we can use it as a map key. | ||
const asBase64 = fromByteArray(rowBytes); | ||
rowId = asBase64; | ||
} | ||
|
||
rows.push({ | ||
type, | ||
rowId, | ||
row, | ||
}); | ||
} | ||
return rows; | ||
}; | ||
|
||
const parseTableUpdate = async ( | ||
rawTableUpdate: RawTableUpdate | ||
): Promise<CacheTableUpdate> => { | ||
|
@@ -366,10 +401,10 @@ export class DbConnectionImpl< | |
decompressed = update.value; | ||
} | ||
operations = operations.concat( | ||
parseRowList('insert', tableName, decompressed.inserts) | ||
this.#parseRowList('insert', tableName, decompressed.inserts) | ||
); | ||
operations = operations.concat( | ||
parseRowList('delete', tableName, decompressed.deletes) | ||
this.#parseRowList('delete', tableName, decompressed.deletes) | ||
); | ||
} | ||
return { | ||
|
@@ -480,9 +515,14 @@ export class DbConnectionImpl< | |
} | ||
|
||
case 'OneOffQueryResponse': { | ||
throw new Error( | ||
`TypeScript SDK never sends one-off queries, but got OneOffQueryResponse ${message}` | ||
); | ||
const queryResolvedMessage: QueryResolvedMessage = { | ||
tag: 'QueryResolved', | ||
messageId: message.value.messageId, | ||
error: message.value.error, | ||
tables: message.value.tables, | ||
totalHostExecutionDuration: message.value.totalHostExecutionDuration, | ||
}; | ||
return queryResolvedMessage; | ||
} | ||
|
||
case 'SubscribeMultiApplied': { | ||
|
@@ -537,6 +577,25 @@ export class DbConnectionImpl< | |
this.isActive = true; | ||
} | ||
|
||
#applyTableState( | ||
tableStates: clientApi.OneOffTable[], | ||
eventContext: EventContextInterface | ||
): Map<TableCache, PendingCallback[]> { | ||
let tables: Map<TableCache, PendingCallback[]> = new Map(); | ||
for (let tableState of tableStates) { | ||
// Get table information for the table being updated | ||
const tableName = tableState.tableName; | ||
const tableTypeInfo = this.#remoteModule.tables[tableName]!; | ||
const table = new TableCache(tableTypeInfo); | ||
const newCallbacks = table.applyOperations( | ||
this.#parseRowList('insert', tableState.tableName, tableState.rows), | ||
eventContext, | ||
); | ||
tables.set(table, newCallbacks); | ||
} | ||
return tables; | ||
} | ||
|
||
#applyTableUpdates( | ||
tableUpdates: CacheTableUpdate[], | ||
eventContext: EventContextInterface | ||
|
@@ -700,6 +759,60 @@ export class DbConnectionImpl< | |
this.#emitter.emit('connect', this, this.identity, this.token); | ||
break; | ||
} | ||
case 'QueryResolved': { | ||
const query = this.#queryManager.queries.get(message.messageId[0]); | ||
if (query === undefined) { | ||
stdbLogger( | ||
'error', | ||
`Received QueryResolved for unknown messageId ${message.messageId}.` | ||
); | ||
break; | ||
} | ||
if (message.error !== undefined) { | ||
const error = Error(message.error); | ||
const event: Event<never> = { tag: 'Error', value: error }; | ||
const eventContext = this.#remoteModule.eventContextConstructor( | ||
this, | ||
event | ||
); | ||
const errorContext = { | ||
...eventContext, | ||
event: error, | ||
}; | ||
if (message.messageId === undefined) { | ||
console.error('Received an error message without a messageId: ', error); | ||
break; | ||
} | ||
this.#queryManager.queries | ||
.get(message.messageId[0]) | ||
?.emitter.emit( | ||
'error', | ||
errorContext, | ||
error, | ||
message.totalHostExecutionDuration | ||
); | ||
} else { | ||
const event: Event<never> = { tag: 'QueryResolved' }; | ||
const eventContext = this.#remoteModule.eventContextConstructor( | ||
this, | ||
event | ||
); | ||
const { event: _, ...queryEventContext } = eventContext; | ||
const tables = this.#applyTableState(message.tables, eventContext); | ||
query?.emitter.emit( | ||
'resolved', | ||
queryEventContext, | ||
tables.keys().reduce((map, table) => | ||
map.set(table.name(), table), new Map()), | ||
message.totalHostExecutionDuration | ||
); | ||
for (const callbacks of tables.values()) | ||
for (const callback of callbacks) | ||
callback.cb(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think we want to call the row-level callbacks for one-off queries, since any rows that are inserted here will never have corresponding delete callbacks. Since we aren't calling those, we don't need to have the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree we can remove the callbacks. But |
||
} | ||
this.#queryManager.queries.delete(message.messageId[0]); | ||
break; | ||
} | ||
case 'SubscribeApplied': { | ||
const subscription = this.#subscriptionManager.subscriptions.get( | ||
message.queryId | ||
|
@@ -784,6 +897,7 @@ export class DbConnectionImpl< | |
emitter.emit('error', errorContext, error); | ||
}); | ||
} | ||
break; | ||
} | ||
} | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The API we use in the C# SDK for running remote queries has them as methods of the
TableHandle
, rather than on theDbContext
. This provides some amount of type safety, as the method inserts theSELECT
clause into the query and also encodes the TypeScript type of the rows returned.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, we use the phrase "remote query" to describe this operation in the user-facing API. This emphasizes that this is a query that bypasses the client cache and goes directly to the remote database.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At the same time, formatting
SELECT
s restricts the provided functionality to querying only. What if a client needs to execute something else, e.g.INSERT
, perhaps with the owner's identity?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, there I discovered (in #2968) that SpacetimeDB does currently not support anything other than
SELECT
even in one-shot queries.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct, we have no interest in generalizing one-off queries to support SQL DML operations like
INSERT
,DELETE
,UPDATE
, &c.