@jskit-ai/resource-runtime 0.2.13 → 0.2.14

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/CHANGELOG.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - A handle's `joinSource()` carries the table's ownership fields (`workspaceId`, `userId`) as join keys, hidden or not, and `column()` names them: a report joining another owner's scoped rows on the workspace, such as a system job reading each workspace's settings beside its bookings, was refused (`contactRoles.joinSource() carries no hidden field, so it has no column for "workspaceId"`). They name the caller's own scope, so nothing secret leaves the owner; every other hidden column stays out, and `column()` still refuses it.
5
6
  - Depends on json-rest-api 3.1.0 exactly (from 3.0.1), from the registry with its published integrity: `lockMode: "noKeyUpdate"`, ON CONFLICT on the matching unique index's exact columns, the PostgreSQL merge race on an uncommitted insert, and the `xid8` field type the change horizon reads.
6
7
  - Tests pin three paths the guide now documents: a shared row of a `workspace_or_shared` table is written in the owner's root system context, naming `workspaceId: null`, also inside a request's transaction, and never from a request or a derived job context (`forUser` is refused 403); a job's `actorId` never moves its scope, while a row policy may hold a job done for a person to that person's rows; and a hand route's `request.callOptions()` scopes user, workspace and workspace_user tables as the same request's action does, with no user scope when no one is signed in.
7
8
  - A table resource reads an xid8 change stamp through an `xid8` field, json-rest-api 3.1.0's type for PostgreSQL's transaction id: decimal text below 2^64, which filters such as `changedSince: { type: "xid8", actualField: "changed", filterOperator: ">=" }` compare as transaction ids in SQL. A malformed cursor is refused as `REST_API_VALIDATION` before any statement, where a string field sent it to PostgreSQL (22P02), and the field is refused at registration on MySQL, MariaDB and SQLite. With `changeHorizon()`, a read of what changed since goes through the handle, where it was a hand-written scoped query; the data-access guide's "What changed since" gives the migration (an xid8 column a trigger stamps on every insert and update), the field, the filters and the read.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/resource-runtime",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24.0.0"
@@ -134,8 +134,8 @@ const HANDLE_SCOPED_QUERIES = new WeakSet();
134
134
  const HANDLE_WORK_RUNNERS = new WeakSet();
135
135
  // The names an owner's own wrappers of those runners keep.
136
136
  const WORK_RUNNER_NAMES = Object.freeze(new Set(["withTransaction", "lockOrCreate"]));
137
- // The builders a handle's joinSource() answers, which carry the table's visible columns only: the one query builder
138
- // withoutHiddenFields hands another feature.
137
+ // The builders a handle's joinSource() answers, which carry the table's visible columns and ownership fields only: the
138
+ // one query builder withoutHiddenFields hands another feature.
139
139
  const JOIN_SOURCES = new WeakSet();
140
140
 
141
141
  /**
@@ -151,13 +151,14 @@ function markHandleFunctions({ scopedQuery, withTransaction, lockOrCreate }) {
151
151
  /**
152
152
  * A handle's joinSource(): the table as a join source for another owner's report, which joins it in its own
153
153
  * statement. It answers `{ query, column }`: the rows scopedQuery() gives under `alias` (the caller's ownership, the
154
- * row policy and `filters` applied) with the stored columns a read answers only, never a hidden one, and
155
- * `column(field)` naming each under that alias. A CRUD feature's repository answers it to any feature, where it
154
+ * row policy and `filters` applied) with the stored columns a read answers and the ownership fields, hidden or not,
155
+ * since a report joins on them and they name the caller's own scope; never another hidden column. `column(field)`
156
+ * names each under that alias. A CRUD feature's repository answers it to any feature, where it
156
157
  * refuses scopedQuery(). The builder comes wrapped, since a builder is thenable.
157
158
  * @param {object} dependencies
158
159
  * @param {{ knex: { instance: any } }} dependencies.api
159
160
  * @param {{ name: string, schema: Record<string, any> }} dependencies.resource
160
- * @param {() => { vars: { schemaInfo: { schemaStructure: Record<string, any> } } }} dependencies.jsonRest
161
+ * @param {() => { vars: { schemaInfo: { schemaStructure: Record<string, any>, ownershipFields?: string[] } } }} dependencies.jsonRest
161
162
  * @param {(params: { alias: string, filters?: object }, options: object) => Promise<{ query: any }>} dependencies.scopedQuery
162
163
  * @param {(field: string, alias?: string) => string} dependencies.column
163
164
  */
@@ -173,22 +174,25 @@ function createJoinSource({ api, resource, jsonRest, scopedQuery, column }) {
173
174
  throw new TypeError(`${name}.joinSource() needs the alias the report joins the ${name} under.`);
174
175
  }
175
176
  const { query } = await scopedQuery({ alias, ...(filters === undefined ? {} : { filters }) }, options);
176
- const { schemaStructure } = jsonRest().vars.schemaInfo;
177
- const visible = Object.keys(schemaStructure).filter((field) => {
177
+ const { schemaStructure, ownershipFields = [] } = jsonRest().vars.schemaInfo;
178
+ const joinKeys = new Set(ownershipFields);
179
+ const carried = Object.keys(schemaStructure).filter((field) => {
178
180
  const definition = schemaStructure[field];
179
- return field !== "id" && definition.hidden !== true && definition.virtual !== true && !definition.belongsToPolymorphic;
181
+ return field !== "id" && (definition.hidden !== true || joinKeys.has(field)) &&
182
+ definition.virtual !== true && !definition.belongsToPolymorphic;
180
183
  });
181
- // The scoped rows' visible columns, as a derived table under the alias: nothing a caller adds reaches the others.
184
+ // The scoped rows' visible columns and ownership fields, as a derived table under the alias: nothing a caller adds
185
+ // reaches the others.
182
186
  const source = readOnlyQueryBuilder(
183
- (options.trx || api.knex.instance).from(query.select(["id", ...visible].map((field) => column(field, alias))).as(alias)),
187
+ (options.trx || api.knex.instance).from(query.select(["id", ...carried].map((field) => column(field, alias))).as(alias)),
184
188
  { resourceType: name }
185
189
  );
186
190
  JOIN_SOURCES.add(source);
187
191
  return Object.freeze({
188
192
  query: source,
189
193
  column(field) {
190
- if (resource.schema[field]?.hidden === true) {
191
- throw new TypeError(`${name}.joinSource() carries no hidden field, so it has no column for "${field}".`);
194
+ if (resource.schema[field]?.hidden === true && !joinKeys.has(field)) {
195
+ throw new TypeError(`${name}.joinSource() carries no hidden field but its ownership fields, so it has no column for "${field}".`);
192
196
  }
193
197
  return column(field, alias);
194
198
  }
@@ -114,7 +114,7 @@ test("withoutHiddenFields refuses another feature's read naming a hidden field,
114
114
  assert.equal(withoutHiddenFields(cards, plain), cards);
115
115
  });
116
116
 
117
- test("withoutHiddenFields refuses another feature's scopedQuery, and any builder a method answers but a joinSource()'s, which carries no hidden column", async (t) => {
117
+ test("withoutHiddenFields refuses another feature's scopedQuery, and any builder a method answers but a joinSource()'s, which carries no hidden column but the ownership fields", async (t) => {
118
118
  const { knex, cards, resource, system, options } = await createCardFixture(t);
119
119
  // Methods an owner's decorateRepository adds with the handle it receives, answering its scoped rows as scopedQuery()
120
120
  // gives them, every column with them: wrapped, and at once.
@@ -145,13 +145,20 @@ test("withoutHiddenFields refuses another feature's scopedQuery, and any builder
145
145
  [{ token: "t1" }, { token: "t2" }, { token: "t3" }]);
146
146
 
147
147
  // The cross-owner report pattern: the table's joinSource() answers the caller's scoped rows, which the report
148
- // selects from, with the columns a read answers and no other.
148
+ // selects from, with the columns a read answers and the ownership fields, and no other.
149
149
  const source = await exposed.joinSource({ alias: "c" }, options);
150
150
  const rows = await source.query.select({ title: source.column("title") }).orderBy(source.column("id"));
151
151
  assert.deepEqual(rows, [{ title: "Rex" }, { title: "Bo" }]);
152
152
  const { query: rex } = await exposed.joinSource({ alias: "c", filters: { title: "Rex" } }, options);
153
- assert.deepEqual((await rex).map((row) => Object.keys(row)), [["id", "title"]]);
154
- assert.throws(() => source.column("publicToken"), /cards\.joinSource\(\) carries no hidden field, so it has no column for "publicToken"/u);
153
+ assert.deepEqual((await rex).map((row) => Object.keys(row)), [["id", "workspace_id", "title"]]);
154
+ assert.throws(() => source.column("publicToken"),
155
+ /cards\.joinSource\(\) carries no hidden field but its ownership fields, so it has no column for "publicToken"/u);
156
+ // The hidden workspaceId is a join key the source carries: a system report counts each workspace's cards on it.
157
+ const perWorkspace = await exposed.joinSource({ alias: "c" }, { context: system });
158
+ const workspaceId = perWorkspace.column("workspaceId");
159
+ const counts = await perWorkspace.query.select({ workspaceId }).count({ cards: perWorkspace.column("id") })
160
+ .groupBy(workspaceId).orderBy(workspaceId);
161
+ assert.deepEqual(counts.map((row) => [Number(row.workspaceId), Number(row.cards)]), [[1, 2], [2, 1]]);
155
162
  const again = await exposed.joinSource({ alias: "c" }, options);
156
163
  await assert.rejects(async () => again.query.select(cards.column("publicToken", "c")), /public_token/u);
157
164
  await assert.rejects(exposed.joinSource({}, options), /cards\.joinSource\(\) needs the alias the report joins the cards under/u);