@fitzzero/quickdraw-lint 5.1.1 → 5.1.3
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 +5 -3
- package/package.json +1 -1
- package/plugin/lib/files.mjs +4 -1
- package/plugin/lib/prisma.mjs +66 -7
- package/plugin/rules/no-foreign-write.mjs +5 -4
- package/plugin/rules/no-nested-write.mjs +3 -2
- package/plugin/rules/no-todo-schema.mjs +1 -1
- package/plugin/rules/no-untracked-write.mjs +14 -5
- package/plugin/rules/no-v4-api.mjs +1 -1
package/README.md
CHANGED
|
@@ -42,9 +42,9 @@ correct code; each rule's file says what it leaves alone.
|
|
|
42
42
|
|
|
43
43
|
| Rule | Reports | Checks by default |
|
|
44
44
|
| ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
|
|
45
|
-
| `no-untracked-write` | importing the untracked Prisma client (`prisma`, `PrismaClient` from the app's db package, `@prisma/client` or the generated client) and writing through a client named `prisma`
|
|
45
|
+
| `no-untracked-write` | importing the untracked Prisma client (`prisma`, `PrismaClient` from the app's db package, `@prisma/client` or the generated client) and writing through a client named `prisma` (one typed `Tracked<...>` or `TrackedTransaction<...>`, from `@fitzzero/quickdraw-core`, is the tracked client whatever its name) | services, jobs, routes |
|
|
46
46
|
| `no-foreign-write` | a write to a model that the enclosing `defineService` neither owns (`model`) nor lists in `writes` | everywhere |
|
|
47
|
-
| `no-nested-write` | a nested relation write (`data: { labels: { create } }`) through the tracked client (`db`, `tx`): only the parent row is tracked
|
|
47
|
+
| `no-nested-write` | a nested relation write (`data: { labels: { create } }`) through the tracked client (`db`, `tx`, or a binding typed `Tracked<...>`): only the parent row is tracked | everywhere |
|
|
48
48
|
| `no-raw-sql-write` | `$executeRaw*`, and `$queryRaw*` running a write, in a function that does not record the rows with `ctx.touch(...)` (or `qd.collections.reset(...)`) | services, jobs, routes |
|
|
49
49
|
| `no-manual-emit` | `.emit()` on the Socket.IO server or a socket (`io.to(room).emit`), and `qd:` strings, the framework's event and room names | services, jobs, routes |
|
|
50
50
|
| `no-inline-auth-guard` | `if (!ctx.principal) throw ...` and its variants inside a `handler` (declare `access` instead), and a kind check there that throws, `if (ctx.principal.kind !== "user") throw ...` (declare `kinds` instead) | everywhere |
|
|
@@ -105,7 +105,9 @@ path relative to where oxlint runs (keep them `**/`-prefixed):
|
|
|
105
105
|
|
|
106
106
|
Other options: `clients` (the names the database client goes by: `["db",
|
|
107
107
|
"tx"]` for the write rules, plus `prisma` for the read rules) on the rules
|
|
108
|
-
that look at database calls
|
|
108
|
+
that look at database calls, where the write rules also take a parameter or
|
|
109
|
+
variable typed `Tracked<...>` or `TrackedTransaction<...>` as the tracked
|
|
110
|
+
client, whatever its name; `modules` (specifiers of the untracked client's
|
|
109
111
|
modules) on `no-untracked-write`; `emitters` on `no-manual-emit`; `sockets`,
|
|
110
112
|
`allowedEvents` and `allowedPrefixes` on `no-raw-socket`; `clients` (the typed
|
|
111
113
|
client's names, `["qd"]`) on `no-untyped-client` and `no-manual-refetch`;
|
package/package.json
CHANGED
package/plugin/lib/files.mjs
CHANGED
|
@@ -38,7 +38,10 @@ export const ROUTE_FILES = Object.freeze(["**/routes/**", "**/routes.*"]);
|
|
|
38
38
|
/** Client code by path: React components and the web app. */
|
|
39
39
|
export const CLIENT_FILES = Object.freeze(["**/*.tsx", "**/*.jsx", "**/apps/web/**"]);
|
|
40
40
|
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Client code by import: a file importing one of these modules or a subpath
|
|
43
|
+
* of one (`@fitzzero/quickdraw-core/client/core`, the client without React).
|
|
44
|
+
*/
|
|
42
45
|
export const CLIENT_MODULES = Object.freeze([
|
|
43
46
|
"@tanstack/react-query",
|
|
44
47
|
"socket.io-client",
|
package/plugin/lib/prisma.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// Recognizing database calls by shape. The tracked client is conventionally
|
|
2
2
|
// named `db` (the handler argument, or the app's `trackPrisma(...)` client in
|
|
3
3
|
// jobs), the client inside an interactive transaction `tx`, and the untracked
|
|
4
|
-
// Prisma client `prisma`. Rules take the names as options.
|
|
4
|
+
// Prisma client `prisma`. Rules take the names as options. A parameter or
|
|
5
|
+
// variable this file declares with a tracked client's type
|
|
6
|
+
// (`prisma: Tracked<Db>`) is the tracked client whatever its name: the
|
|
7
|
+
// compiler refuses the untracked client there (`declaredTracked`).
|
|
5
8
|
|
|
6
|
-
import { memberName, unwrap } from "./ast.mjs";
|
|
9
|
+
import { memberName, resolveVariable, unwrap } from "./ast.mjs";
|
|
7
10
|
|
|
8
11
|
/** Prisma model operations that write rows. */
|
|
9
12
|
export const WRITE_METHODS = new Set([
|
|
@@ -39,6 +42,9 @@ export const TRACKED_CLIENTS = Object.freeze(["db", "tx"]);
|
|
|
39
42
|
/** Every client name, tracked or not. */
|
|
40
43
|
export const ALL_CLIENTS = Object.freeze(["db", "tx", "prisma"]);
|
|
41
44
|
|
|
45
|
+
/** The types that hold a client to the tracked one: `@fitzzero/quickdraw-core`'s `Tracked<C>` and `TrackedTransaction<C>`. */
|
|
46
|
+
export const TRACKED_TYPES = Object.freeze(["Tracked", "TrackedTransaction"]);
|
|
47
|
+
|
|
42
48
|
/** The JSON Schema of a `clients` option. */
|
|
43
49
|
export const CLIENTS_OPTION = Object.freeze({
|
|
44
50
|
type: "array",
|
|
@@ -62,12 +68,62 @@ export function clientName(node, clients) {
|
|
|
62
68
|
return undefined;
|
|
63
69
|
}
|
|
64
70
|
|
|
71
|
+
/** The name a type reference ends in: `Tracked` for `Tracked<Db>` and for `quickdraw.Tracked<Db>`. */
|
|
72
|
+
function referenceName(name) {
|
|
73
|
+
if (name.type === "Identifier") {
|
|
74
|
+
return name.name;
|
|
75
|
+
}
|
|
76
|
+
return name.type === "TSQualifiedName" ? referenceName(name.right) : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether a type says the tracked client, by its text (the rule cannot see
|
|
81
|
+
* types): `Tracked<...>` or `TrackedTransaction<...>`, qualified or not, a
|
|
82
|
+
* union of only those, or an intersection with one.
|
|
83
|
+
*/
|
|
84
|
+
export function isTrackedType(type) {
|
|
85
|
+
switch (type?.type) {
|
|
86
|
+
case "TSTypeReference":
|
|
87
|
+
return TRACKED_TYPES.includes(referenceName(type.typeName));
|
|
88
|
+
case "TSUnionType":
|
|
89
|
+
return type.types.every(isTrackedType);
|
|
90
|
+
case "TSIntersectionType":
|
|
91
|
+
return type.types.some(isTrackedType);
|
|
92
|
+
default:
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether `node` names a parameter or variable this file declares with a
|
|
99
|
+
* tracked client's type (`prisma: Tracked<Db>`, `const client:
|
|
100
|
+
* TrackedTransaction<Db> = tx`): the compiler holds it to the tracked client,
|
|
101
|
+
* whatever it is named. A destructured binding, a member and a name declared
|
|
102
|
+
* elsewhere are not read.
|
|
103
|
+
*/
|
|
104
|
+
export function declaredTracked(context, node) {
|
|
105
|
+
const receiver = unwrap(node);
|
|
106
|
+
if (receiver?.type !== "Identifier") {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
const definitions = resolveVariable(context, receiver)?.defs ?? [];
|
|
110
|
+
return (
|
|
111
|
+
definitions.length > 0 &&
|
|
112
|
+
definitions.every((definition) =>
|
|
113
|
+
isTrackedType(definition.name?.typeAnnotation?.typeAnnotation),
|
|
114
|
+
)
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
65
118
|
/**
|
|
66
119
|
* A model operation on one of `clients`: `db.task.update(...)` gives
|
|
67
|
-
* `{ client: "db", model: "task", method: "update", args }
|
|
68
|
-
* model (`db[name]`) and the
|
|
120
|
+
* `{ client: "db", model: "task", method: "update", args, receiver }`, the
|
|
121
|
+
* receiver being the client's node. A computed model (`db[name]`) and the
|
|
122
|
+
* client's own `$` methods are not model calls. Given the rule's `context`,
|
|
123
|
+
* a receiver declared with a tracked client's type counts as one of
|
|
124
|
+
* `clients` whatever its name (`declaredTracked`).
|
|
69
125
|
*/
|
|
70
|
-
export function modelCall(node, clients) {
|
|
126
|
+
export function modelCall(node, clients, context) {
|
|
71
127
|
const call = unwrap(node);
|
|
72
128
|
if (call?.type !== "CallExpression") {
|
|
73
129
|
return undefined;
|
|
@@ -88,9 +144,12 @@ export function modelCall(node, clients) {
|
|
|
88
144
|
if (model === undefined || model.startsWith("$")) {
|
|
89
145
|
return undefined;
|
|
90
146
|
}
|
|
91
|
-
const
|
|
147
|
+
const receiver = unwrap(delegate.object);
|
|
148
|
+
const client =
|
|
149
|
+
clientName(receiver, clients) ??
|
|
150
|
+
(context !== undefined && declaredTracked(context, receiver) ? receiver.name : undefined);
|
|
92
151
|
if (client === undefined) {
|
|
93
152
|
return undefined;
|
|
94
153
|
}
|
|
95
|
-
return { call, client, model, method, args: call.arguments };
|
|
154
|
+
return { call, client, model, method, args: call.arguments, receiver };
|
|
96
155
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// A service writes its own model and the models it lists in `writes`
|
|
2
2
|
// (RFC 0003 sections 3 and 14). This rule reads both from the
|
|
3
3
|
// `defineService(contract, { model, writes })` call a write sits in, so it
|
|
4
|
-
// needs no map of owners: a write to any other model through `db` (or `tx
|
|
5
|
-
//
|
|
6
|
-
// `
|
|
4
|
+
// needs no map of owners: a write to any other model through `db` (or `tx`,
|
|
5
|
+
// or a parameter or variable declared with a tracked client's type,
|
|
6
|
+
// `Tracked<Db>`) is reported. A write outside a `defineService` call, or in
|
|
7
|
+
// one whose `model` or `writes` is not written out literally, is not checked.
|
|
7
8
|
|
|
8
9
|
import { getProperty, hasSpread, isDefineService, staticString, unwrap } from "../lib/ast.mjs";
|
|
9
10
|
import { CLIENTS_OPTION, TRACKED_CLIENTS, WRITE_METHODS, modelCall } from "../lib/prisma.mjs";
|
|
@@ -87,7 +88,7 @@ export default {
|
|
|
87
88
|
const cache = new WeakMap();
|
|
88
89
|
return {
|
|
89
90
|
CallExpression(node) {
|
|
90
|
-
const call = modelCall(node, clients);
|
|
91
|
+
const call = modelCall(node, clients, context);
|
|
91
92
|
if (call === undefined || !WRITE_METHODS.has(call.method)) {
|
|
92
93
|
return;
|
|
93
94
|
}
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// JSON column) is not one, while any other expression may be rows; `delete`
|
|
8
8
|
// and `disconnect` also take `true` (a to-one relation), not `false`; `set`
|
|
9
9
|
// counts only when it is given objects (a relation), not a scalar or a scalar
|
|
10
|
-
// list.
|
|
10
|
+
// list. The tracked client is `db` or `tx`, or a parameter or variable
|
|
11
|
+
// declared with a tracked client's type (`prisma: Tracked<Db>`).
|
|
11
12
|
|
|
12
13
|
import { getProperty, keyName, unwrap } from "../lib/ast.mjs";
|
|
13
14
|
import { CLIENTS_OPTION, TRACKED_CLIENTS, modelCall } from "../lib/prisma.mjs";
|
|
@@ -118,7 +119,7 @@ export default {
|
|
|
118
119
|
const clients = context.options[0]?.clients ?? TRACKED_CLIENTS;
|
|
119
120
|
return {
|
|
120
121
|
CallExpression(node) {
|
|
121
|
-
const call = modelCall(node, clients);
|
|
122
|
+
const call = modelCall(node, clients, context);
|
|
122
123
|
const argument = call?.args[0] === undefined ? undefined : unwrap(call.args[0]);
|
|
123
124
|
if (argument?.type !== "ObjectExpression") {
|
|
124
125
|
return;
|
|
@@ -116,7 +116,7 @@ export default {
|
|
|
116
116
|
keylessInput:
|
|
117
117
|
"`todoSchema()` is a placeholder left by the 4.x migration: it validates nothing, so any value passes as its type. " +
|
|
118
118
|
"Without `keys`, it also hides this input's keys from defineService's id-reach (rowless) check, " +
|
|
119
|
-
"which refuses an `id` input under an access form that
|
|
119
|
+
"which refuses an `id` input under an access form that asks the policy nothing about the row it names; give it keys " +
|
|
120
120
|
'(`todoSchema<T>({ keys: ["id", ...] })`) until a real schema (Zod 4.2 or later) replaces it.',
|
|
121
121
|
},
|
|
122
122
|
schema: [],
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
// script or webhook the app's `trackPrisma(...)` client inside `qd.run`.
|
|
4
4
|
// This rule keeps the untracked Prisma client out of services, jobs and
|
|
5
5
|
// routes: importing it from the app's database package, and writing through
|
|
6
|
-
// a client named `prisma`. Reads through it are left alone
|
|
6
|
+
// a client named `prisma`. Reads through it are left alone, and so is a
|
|
7
|
+
// write through a parameter or variable declared with a tracked client's
|
|
8
|
+
// type (`prisma: Tracked<Db>`), whatever its name: the compiler refuses the
|
|
9
|
+
// untracked client there.
|
|
7
10
|
|
|
8
11
|
import { FILE_OPTIONS, SERVER_FILES, TEST_FILES, inScope, matchesAny } from "../lib/files.mjs";
|
|
9
|
-
import { WRITE_METHODS, modelCall } from "../lib/prisma.mjs";
|
|
12
|
+
import { WRITE_METHODS, declaredTracked, modelCall } from "../lib/prisma.mjs";
|
|
10
13
|
|
|
11
14
|
const DEFAULT_MODULES = ["@project/db", "@prisma/client", "**/generated/prisma/client*"];
|
|
12
15
|
const DEFAULT_CLIENTS = ["prisma"];
|
|
@@ -31,7 +34,8 @@ export default {
|
|
|
31
34
|
"Use the handler's `db` argument; in a job, script or webhook, import the tracked client (`trackPrisma(...)`) and wrap its writes in `qd.run(() => ...)`.",
|
|
32
35
|
untrackedWrite:
|
|
33
36
|
"`{{ client }}.{{ model }}.{{ method }}()` writes through the untracked client, so subscribers never see this change. " +
|
|
34
|
-
"Write through the handler's `db` argument, or through the tracked client inside `qd.run(() => ...)`."
|
|
37
|
+
"Write through the handler's `db` argument, or through the tracked client inside `qd.run(() => ...)`. " +
|
|
38
|
+
"A parameter or variable given the tracked client says so with its type, whatever its name: `Tracked<Db>`.",
|
|
35
39
|
},
|
|
36
40
|
schema: [
|
|
37
41
|
{
|
|
@@ -46,7 +50,8 @@ export default {
|
|
|
46
50
|
clients: {
|
|
47
51
|
type: "array",
|
|
48
52
|
items: { type: "string" },
|
|
49
|
-
description:
|
|
53
|
+
description:
|
|
54
|
+
"Names the untracked client goes by; one declared with a tracked client's type (`Tracked<Db>`) is not it.",
|
|
50
55
|
},
|
|
51
56
|
},
|
|
52
57
|
additionalProperties: false,
|
|
@@ -80,7 +85,11 @@ export default {
|
|
|
80
85
|
},
|
|
81
86
|
CallExpression(node) {
|
|
82
87
|
const call = modelCall(node, clients);
|
|
83
|
-
if (
|
|
88
|
+
if (
|
|
89
|
+
call === undefined ||
|
|
90
|
+
!WRITE_METHODS.has(call.method) ||
|
|
91
|
+
declaredTracked(context, call.receiver)
|
|
92
|
+
) {
|
|
84
93
|
return;
|
|
85
94
|
}
|
|
86
95
|
context.report({
|
|
@@ -17,7 +17,7 @@ const MCP = "@fitzzero/quickdraw-core/server/mcp";
|
|
|
17
17
|
const METHODS =
|
|
18
18
|
"Declare methods in a contract (`defineContract(name, { methods: { name: query({ input, output }) } })`), type them with `InputOf<C, M>` and `OutputOf<C, M>`, and implement them as `{ access, handler({ input, ctx, db }) }`.";
|
|
19
19
|
const CHANNELS =
|
|
20
|
-
"Declare channels in the contract (`channels: { name: { payload } }`), handle them in `qd.defineService(contract, { channels })`, and send with `qd.<service>.<channel>.useChannel()`.";
|
|
20
|
+
"Declare channels in the contract (`channels: { name: { payload, requires } }`, where `requires` names what the sender must hold, such as `{ room }`), handle them in `qd.defineService(contract, { channels })`, and send with `qd.<service>.<channel>.useChannel()`.";
|
|
21
21
|
const EVENTS =
|
|
22
22
|
"Declare room events in the contract (`events: { name: { payload } }`), send them with `ctx.rooms.emit(room, contract, event, payload)`, and listen with `qd.<service>.<event>.useEvent(handler)`.";
|
|
23
23
|
const ADMIN_TYPES =
|