@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 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` | services, jobs, routes |
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 | everywhere |
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; `modules` (specifiers of the untracked client's
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitzzero/quickdraw-lint",
3
- "version": "5.1.1",
3
+ "version": "5.1.3",
4
4
  "description": "oxlint plugin, base config and baseline command for quickdraw apps",
5
5
  "keywords": [
6
6
  "lint",
@@ -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
- /** Client code by import: a file importing one of these modules (or a subpath of them). */
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",
@@ -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 }`. A computed
68
- * model (`db[name]`) and the client's own `$` methods are not model calls.
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 client = clientName(delegate.object, clients);
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
- // is reported. A write outside a `defineService` call, or in one whose
6
- // `model` or `writes` is not written out literally, is not checked.
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 checks no row; give it keys " +
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: "Names the untracked client goes by.",
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 (call === undefined || !WRITE_METHODS.has(call.method)) {
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 =