Skip to content

Commit 0268858

Browse files
authored
Warn when auto-subscribe streams use parameters (#381)
1 parent b4fa979 commit 0268858

File tree

7 files changed

+92
-40
lines changed

7 files changed

+92
-40
lines changed

.changeset/wet-games-rush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@powersync/service-sync-rules': patch
3+
---
4+
5+
Warn when a sync stream with auto-subscribe uses stream parameters.

packages/service-core/test/src/routes/stream.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { logger, RouterResponse, ServiceError } from '@powersync/lib-services-fr
33
import { SqlSyncRules } from '@powersync/service-sync-rules';
44
import { Readable, Writable } from 'stream';
55
import { pipeline } from 'stream/promises';
6-
import { beforeEach, describe, expect, it, vi } from 'vitest';
6+
import { describe, expect, it } from 'vitest';
77
import { syncStreamed } from '../../../src/routes/endpoints/sync-stream.js';
88
import { mockServiceContext } from './mocks.js';
99

packages/sync-rules/src/request_functions.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,16 @@ export interface SqlParameterFunction {
88
call: (parameters: ParameterValueSet, ...args: SqliteValue[]) => SqliteValue;
99
getReturnType(): ExpressionType;
1010
parameterCount: number;
11-
/** request.user_id(), request.jwt(), token_parameters.* */
12-
usesAuthenticatedRequestParameters: boolean;
13-
/** request.parameters(), user_parameters.* */
14-
usesUnauthenticatedRequestParameters: boolean;
11+
/**
12+
* Whether this function returns data derived from usage parameters.
13+
*
14+
* This can be:
15+
*
16+
* 1. `subscription`, for unauthenticated subscription parameters like `subscription.parameters()`.
17+
* 2. `authenticated`, for parameters authenticated by a trusted backend (like `request.user_id()`).
18+
* 3. `unauthenticated`, for global unauthenticated request parameters like (like `request.parameters()`).
19+
*/
20+
parameterUsage: 'subscription' | 'authenticated' | 'unauthenticated' | null;
1521
detail: string;
1622
documentation: string;
1723
}
@@ -31,8 +37,7 @@ export function parameterFunctions(options: {
3137
extractJsonParsed: (v: ParameterValueSet) => any;
3238
sourceDescription: string;
3339
sourceDocumentation: string;
34-
usesAuthenticatedRequestParameters: boolean;
35-
usesUnauthenticatedRequestParameters: boolean;
40+
parameterUsage: SqlParameterFunction['parameterUsage'];
3641
}) {
3742
const allParameters: SqlParameterFunction = {
3843
debugName: `${options.schema}.parameters`,
@@ -45,8 +50,7 @@ export function parameterFunctions(options: {
4550
},
4651
detail: options.sourceDescription,
4752
documentation: `Returns ${options.sourceDocumentation}`,
48-
usesAuthenticatedRequestParameters: options.usesAuthenticatedRequestParameters,
49-
usesUnauthenticatedRequestParameters: options.usesUnauthenticatedRequestParameters
53+
parameterUsage: options.parameterUsage
5054
};
5155

5256
const extractParameter: SqlParameterFunction = {
@@ -68,8 +72,7 @@ export function parameterFunctions(options: {
6872
},
6973
detail: `Extract value from ${options.sourceDescription}`,
7074
documentation: `Returns an extracted value (via the key as the second argument) from ${options.sourceDocumentation}`,
71-
usesAuthenticatedRequestParameters: options.usesAuthenticatedRequestParameters,
72-
usesUnauthenticatedRequestParameters: options.usesUnauthenticatedRequestParameters
75+
parameterUsage: options.parameterUsage
7376
};
7477

7578
return { parameters: allParameters, parameter: extractParameter };
@@ -87,8 +90,7 @@ export function globalRequestParameterFunctions(schema: string) {
8790
sourceDescription: 'Unauthenticated request parameters as JSON',
8891
sourceDocumentation:
8992
'parameters passed by the client as a JSON string. These parameters are not authenticated - any value can be passed in by the client.',
90-
usesAuthenticatedRequestParameters: false,
91-
usesUnauthenticatedRequestParameters: true
93+
parameterUsage: 'unauthenticated'
9294
});
9395
}
9496

@@ -103,8 +105,7 @@ export const request_jwt: SqlParameterFunction = {
103105
},
104106
detail: 'JWT payload as JSON',
105107
documentation: 'The JWT payload as a JSON string. This is always validated against trusted keys.',
106-
usesAuthenticatedRequestParameters: true,
107-
usesUnauthenticatedRequestParameters: false
108+
parameterUsage: 'authenticated'
108109
};
109110

110111
export function generateUserIdFunction(debugName: string, sameAsDesc: string): SqlParameterFunction {
@@ -119,8 +120,7 @@ export function generateUserIdFunction(debugName: string, sameAsDesc: string): S
119120
},
120121
detail: 'Authenticated user id',
121122
documentation: `The id of the authenticated user.\nSame as \`${sameAsDesc} ->> 'sub'\`.`,
122-
usesAuthenticatedRequestParameters: true,
123-
usesUnauthenticatedRequestParameters: false
123+
parameterUsage: 'authenticated'
124124
};
125125
}
126126

packages/sync-rules/src/streams/from_sql.ts

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
} from 'pgsql-ast-parser';
4343
import { STREAM_FUNCTIONS } from './functions.js';
4444
import { CompatibilityEdition } from '../compatibility.js';
45+
import { DetectRequestParameters } from '../validators.js';
4546

4647
export function syncStreamFromSql(
4748
descriptorName: string,
@@ -56,6 +57,7 @@ class SyncStreamCompiler {
5657
descriptorName: string;
5758
sql: string;
5859
options: StreamParseOptions;
60+
parameterDetector: DetectRequestParameters = new DetectRequestParameters();
5961

6062
errors: SqlRuleError[];
6163

@@ -108,6 +110,19 @@ class SyncStreamCompiler {
108110
}
109111

110112
this.errors.push(...tools.errors);
113+
if (this.parameterDetector.usesStreamParameters && stream.subscribedToByDefault) {
114+
const error = new SqlRuleError(
115+
'Clients subscribe to this stream by default, but it uses subscription parameters. Default subscriptions use ' +
116+
'null for all parameters, which can lead to unintentional results. Try removing the parameter or not ' +
117+
'marking the stream as auto-subscribe.',
118+
tools.sql,
119+
undefined
120+
);
121+
error.type = 'warning';
122+
123+
this.errors.push(error);
124+
}
125+
111126
return stream;
112127
}
113128

@@ -259,7 +274,8 @@ class SyncStreamCompiler {
259274
}
260275

261276
const regularClause = tools.compileClause(clause);
262-
return compiledClauseToFilter(tools, clause?._location ?? null, regularClause);
277+
this.parameterDetector.accept(regularClause);
278+
return this.compiledClauseToFilter(tools, clause?._location ?? null, regularClause);
263279
}
264280

265281
private compileInOperator(tools: SqlTools, clause: ExprBinary): FilterOperator {
@@ -308,7 +324,7 @@ class SyncStreamCompiler {
308324
// left clause doesn't depend on row data however, we can push it down into the subquery where it would be
309325
// introduced as a parameter: `EXISTS (SELECT _ FROM users WHERE is_admin AND user_id = request.user_id())`.
310326
const additionalClause = subqueryTools.parameterMatchClause(subquery.column, left);
311-
subquery.addFilter(compiledClauseToFilter(subqueryTools, null, additionalClause));
327+
subquery.addFilter(this.compiledClauseToFilter(subqueryTools, null, additionalClause));
312328
return new ExistsOperator(location, subquery);
313329
} else {
314330
// Case 1
@@ -322,7 +338,7 @@ class SyncStreamCompiler {
322338
// a ParameterMatchClause, which we can translate via CompareRowValueWithStreamParameter. Case 5 is either a row-value
323339
// or a parameter-value clause which we can wrap in EvaluateSimpleCondition.
324340
const combined = tools.compileInClause(clause.left, left, clause.right, right);
325-
return compiledClauseToFilter(tools, location, combined);
341+
return this.compiledClauseToFilter(tools, location, combined);
326342
}
327343

328344
private compileOverlapOperator(tools: SqlTools, clause: ExprBinary): FilterOperator {
@@ -356,7 +372,7 @@ class SyncStreamCompiler {
356372
// a ParameterMatchClause, which we can translate via CompareRowValueWithStreamParameter. Case 5 is either a row-value
357373
// or a parameter-value clause which we can wrap in EvaluateSimpleCondition.
358374
const combined = tools.compileOverlapClause(clause.left, left, clause.right, right);
359-
return compiledClauseToFilter(tools, location, combined);
375+
return this.compiledClauseToFilter(tools, location, combined);
360376
}
361377

362378
private compileSubquery(stmt: SelectStatement): [Subquery, SqlTools] | undefined {
@@ -399,7 +415,10 @@ class SyncStreamCompiler {
399415
const where = tools.compileClause(query.where);
400416

401417
this.errors.push(...tools.errors);
402-
return [new Subquery(sourceTable, column, compiledClauseToFilter(tools, query.where?._location, where)), tools];
418+
return [
419+
new Subquery(sourceTable, column, this.compiledClauseToFilter(tools, query.where?._location, where)),
420+
tools
421+
];
403422
}
404423

405424
private checkValidSelectStatement(stmt: Statement) {
@@ -446,6 +465,20 @@ class SyncStreamCompiler {
446465
sourceTable
447466
};
448467
}
468+
469+
compiledClauseToFilter(tools: SqlTools, location: NodeLocation | nil, regularClause: CompiledClause) {
470+
this.parameterDetector.accept(regularClause);
471+
472+
if (isScalarExpression(regularClause)) {
473+
return new EvaluateSimpleCondition(location ?? null, regularClause);
474+
} else if (isParameterMatchClause(regularClause)) {
475+
return new CompareRowValueWithStreamParameter(location ?? null, regularClause);
476+
} else if (isClauseError(regularClause)) {
477+
return recoverErrorClause(tools);
478+
} else {
479+
throw new Error('Unknown clause type');
480+
}
481+
}
449482
}
450483

451484
function isScalarExpression(clause: CompiledClause): clause is ScalarExpression {
@@ -456,15 +489,3 @@ function recoverErrorClause(tools: SqlTools): EvaluateSimpleCondition {
456489
// An error has already been logged.
457490
return new EvaluateSimpleCondition(null, tools.compileClause(null) as StaticValueClause);
458491
}
459-
460-
function compiledClauseToFilter(tools: SqlTools, location: NodeLocation | nil, regularClause: CompiledClause) {
461-
if (isScalarExpression(regularClause)) {
462-
return new EvaluateSimpleCondition(location ?? null, regularClause);
463-
} else if (isParameterMatchClause(regularClause)) {
464-
return new CompareRowValueWithStreamParameter(location ?? null, regularClause);
465-
} else if (isClauseError(regularClause)) {
466-
return recoverErrorClause(tools);
467-
} else {
468-
throw new Error('Unknown clause type');
469-
}
470-
}

packages/sync-rules/src/streams/functions.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ export const STREAM_FUNCTIONS: Record<string, Record<string, SqlParameterFunctio
1919
sourceDescription: 'Unauthenticated subscription parameters as JSON',
2020
sourceDocumentation:
2121
'parameters passed by the client for this stream as a JSON string. These parameters are not authenticated - any value can be passed in by the client.',
22-
usesAuthenticatedRequestParameters: false,
23-
usesUnauthenticatedRequestParameters: true
22+
parameterUsage: 'subscription'
2423
})
2524
},
2625
connection: {
@@ -38,8 +37,7 @@ export const STREAM_FUNCTIONS: Record<string, Record<string, SqlParameterFunctio
3837
},
3938
sourceDescription: 'JWT payload as JSON',
4039
sourceDocumentation: 'JWT payload as a JSON string. This is always validated against trusted keys',
41-
usesAuthenticatedRequestParameters: true,
42-
usesUnauthenticatedRequestParameters: false
40+
parameterUsage: 'authenticated'
4341
})
4442
}
4543
};

packages/sync-rules/src/validators.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export class DetectRequestParameters {
1010
usesAuthenticatedRequestParameters: boolean = false;
1111
/** request.parameters(), user_parameters.* */
1212
usesUnauthenticatedRequestParameters: boolean = false;
13+
/** subscription.parameters(), subscription.parameters('a') */
14+
usesStreamParameters: boolean = false;
1315

1416
accept(clause?: CompiledClause) {
1517
if (clause == null) {
@@ -19,8 +21,10 @@ export class DetectRequestParameters {
1921
if (isRequestFunctionCall(clause)) {
2022
const f = clause.function;
2123

22-
this.usesAuthenticatedRequestParameters ||= f.usesAuthenticatedRequestParameters;
23-
this.usesUnauthenticatedRequestParameters ||= f.usesUnauthenticatedRequestParameters;
24+
this.usesAuthenticatedRequestParameters ||= f.parameterUsage == 'authenticated';
25+
this.usesUnauthenticatedRequestParameters ||=
26+
f.parameterUsage == 'unauthenticated' || f.parameterUsage == 'subscription';
27+
this.usesStreamParameters ||= f.parameterUsage == 'subscription';
2428
} else if (isLegacyParameterFromTableClause(clause)) {
2529
const table = clause.table;
2630
if (table == 'token_parameters') {

packages/sync-rules/test/src/streams.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,30 @@ describe('streams', () => {
513513
expect.toBeSqlRuleError("Function 'request.user_id' is not defined", 'request.user_id()')
514514
]);
515515
});
516+
517+
describe('auto-subscribe with parameters', () => {
518+
const optionsWithAutoSubscribe = { ...options, auto_subscribe: true };
519+
520+
function expectWarning(sql: string) {
521+
const [_, errors] = syncStreamFromSql('s', sql, optionsWithAutoSubscribe);
522+
expect(errors).toHaveLength(1);
523+
524+
const error = errors[0];
525+
expect(error.message).toContain(
526+
'Clients subscribe to this stream by default, but it uses subscription parameters'
527+
);
528+
}
529+
530+
test('in simple filter', () => {
531+
expectWarning(`SELECT * FROM issues WHERE id = subscription.parameter('s')`);
532+
});
533+
534+
test('in subquery', () => {
535+
expectWarning(
536+
`SELECT * FROM issues WHERE owner_id IN (SELECT id FROM "users" WHERE id = subscription.parameter('s'))`
537+
);
538+
});
539+
});
516540
});
517541

518542
describe('normalization', () => {

0 commit comments

Comments
 (0)