Skip to content

Commit 13407b7

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21066-memory-json-column-family
2 parents 5b75fe4 + 58a77db commit 13407b7

34 files changed

Lines changed: 2097 additions & 330 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/metadata-core': patch
3+
---
4+
5+
The shared engine case tables and the published contract suites in metadata-core no longer cite tracker numbers in their case labels; each label states its case in words
6+
7+
Clause-②: no
8+
9+
Several labels these tables and suites ship ended with an issue-tracker number where the case belonged. A
10+
test driven from them printed that number as part of its name, and a failing assertion quoted it as the
11+
reason. The number goes; where the label did not already say what the case is, it now does.
12+
13+
- `ENGINE_DELETE_DISPATCH_CASES`, `ENGINE_UPDATE_DISPATCH_CASES` and `ENGINE_FINDONE_PREDICATE_CASES`:
14+
the `what` labels of 22 rows. Among them, the compare-and-set rows now say the by-id path would drop the
15+
CAS guard; the payload-id rows say which declared `where.id` would be silently dropped; and the falsy
16+
`where.id` boundary says it is a scalar, so neither the different-row refusal nor the non-scalar refusal
17+
applies.
18+
- `@objectstack/metadata-core/testing`: the repository contract suite's `serialized-form identity` group
19+
title, and two `why` texts of `OBJECT_SCHEMA_MASK_CASES` (the empty-readable-set refusal, and the
20+
write-capable exemption, which now names the schema write gate, `manage_metadata`).
21+
22+
Text only: no case is added, removed or re-ordered, and no `options`, `data`, `expect`, `expectId`, `id`,
23+
`readable` or `context` value moves. A suite that selects or skips these cases by their label text (a
24+
`-t` filter, a skip list) needs the new spelling.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/service-knowledge': patch
3+
---
4+
5+
fix(service-knowledge): the realtime event bridge no longer keeps a `record.created` / `record.updated` / `record.deleted` branch, and its docs name the events ObjectQL really publishes (#20573)
6+
7+
No producer emits a bare `record.*` event: the ObjectQL engine publishes `data.record.created` / `data.record.updated` / `data.record.deleted` for a single-record write and `data.records.updated` / `data.records.deleted` for a predicate write (`multi: true`), and `@objectstack/spec` already dropped the bare names from `RealtimeEventType`. The `KnowledgeServicePlugin` subscription handler still carried a branch for them, with a `payload.record ?? payload` fallback for a shape nothing sends. That branch is removed. The `data.record.*` sync (record body from `after`, id from `recordId`) and the `data.records.*` stale-index warning are unchanged.
8+
9+
The `enableEventSync` option's TSDoc and the `handleRecordUpsert` / `handleRecordDelete` docs now name `data.record.created|updated|deleted` instead of `record.*`.
10+
11+
If a plugin of yours publishes a bare `record.created`, `record.updated` or `record.deleted` event through `IRealtimeService` and relied on the knowledge index following it, publish `data.record.created|updated|deleted` with the `DataEvent` payload instead (the record in `after`, its id in `recordId`).
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/service-analytics': minor
4+
'@objectstack/driver-sql': patch
5+
---
6+
7+
fix(service-analytics)!: the analytics read scope and the native `where` answer `$contains` / `$notContains` on a multi-valued or JSON-stored field by membership, with the one construct `driver-sql` emits, now exported from `@objectstack/core` (#20987)
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) a correction of which rows two analytics SQL faces answer for one operator on one declared field class: the read scope `compileScopedFilterToSql` compiles from a row policy, and the native strategy's rendering of a query's `where`. No authorable key, spelling, export or stored shape of metadata moves; `packages/spec` is untouched, and the contract sentence the faces now meet (`FILTER_OPERATORS.$contains`) is the one already declared. A policy or filter that was written stays written as it was, and what it now selects is what the data door already selected for it, so there is nothing a ledger entry could rewrite. The new refusal on a datasource whose SQL dialect the host cannot name is a refusal of a query, not of stored metadata. The other categories are closed on facts: the packages publish (not `unpublished`); no ADR-0087 id covers a filter operator's reading (not `registered` / `already-registered`); and the change is runtime behaviour plus one additive export, not a declaration change (not `runtime-interface-only` / `type-surface-only`). -->
12+
13+
**BREAKING**: this narrows what the analytics doors answer for one class of read. A row policy (the read scope the analytics plugin compiles from the security service, or a host's own `getReadScope`) whose `$contains` or `$notContains` names a field declared multi-valued (`multiple: true` on a multi-capable type, or a multi-option type) or JSON-stored now selects the rows holding the comparand as an ELEMENT of the stored list. It used to select every row whose stored JSON text contained the comparand as a substring, so on SQLite a policy could admit rows outside it, and on PostgreSQL every query under such a policy answered `500` (MySQL was not measured). An analytics count under such a policy now equals what the same caller reads through the data door. On a datasource whose SQL dialect the analytics host cannot name, such a policy now refuses the query (`READ_SCOPE_COMPILE_FAILED` / `500`) instead of falling back to the substring reading. It ships as `minor` under the launch-window convention.
14+
15+
**The `where`.** `POST /api/v1/analytics/query`, the dataset door and `/analytics/sql` on the native strategy render the same membership test for a `$contains` / `$notContains` in a query's `where` (or a dataset's `runtimeFilter`) on such a field: on PostgreSQL the query answers rows where it answered `500`, and on SQLite the count stops over-counting (`$contains`) and under-counting (`$notContains`). On a datasource whose dialect the host cannot name, the operator on such a field is refused `INVALID_FILTER` / `400`. The ObjectQL strategy already answered membership and is unchanged.
16+
17+
**Unchanged.** On a scalar text field `$contains` stays the substring test, on every face. `$notContains` keeps its NULL rule: a row with no value satisfies it. A host that wires no field metadata keeps the substring reading, because it cannot tell a JSON column from a text one; the analytics plugin wires it from the data engine.
18+
19+
**New export.** `@objectstack/core` exports `jsonMembershipPredicate(dialect, emitters, value)` and `jsonMembershipCandidates(value)`, with the `JsonMembershipDialect` and `JsonMembershipEmitters` types: the per-dialect membership construct (#17590) moved from `@objectstack/driver-sql`, where it was module-private, and made placeholder-agnostic. `@objectstack/driver-sql` imports it and emits byte-identical statements and bindings.
20+
21+
**What to do after upgrading.** Nothing, unless a policy or a dashboard filter relied on the substring reading of a multi-valued or JSON-stored field: such a filter now selects members only, as the data door always did. A host whose analytics `sqlDialect` hook answers nothing for a SQL datasource should answer `'sqlite'`, `'postgres'` or `'mysql'`, or the operator on such a field is refused.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the `object-kanban` `quickAdd` retirement no longer sends authors to the `kanban-ui` block, which objectui does not register
6+
7+
Clause-②: no
8+
9+
- The refusal of `quickAdd` on `object-kanban` now ends "Delete the key; `object-kanban` offers no quick-add control." It used to say the control "is unchanged on the `kanban-ui` block". objectui retired that block (objectui#8257), so a node of that type saves clean and renders nothing. The refusal itself is unchanged: the same key is still refused, with the same code and path.
10+
- The same sentence replaces the old one in the `os migrate meta --from 17` output (the `object-kanban-quick-add-retired` entry) and in the summary of the `object-kanban-quick-add-removed` conversion. That entry no longer offers "move the board to a host that renders the `kanban-ui` block" as a second way out.
11+
- No author action beyond the existing one. `quickAdd: true` on an `object-kanban` is still a parse error. Delete the key.

‎content/docs/references/ui/component.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -822,7 +822,7 @@ Sort field and direction pair
822822
| **cardFields** | `string[]` | optional | Fields rendered on each card |
823823
| **swimlaneField** | `string` | optional | Field for horizontal swimlanes (in addition to columns) |
824824
| **grouping** | `{ fields: object[] }` | optional | View grouping config; its first field is the swimlane fallback |
825-
| **quickAdd** | `never` | optional | [REMOVED] `object-kanban` property `quickAdd` was removed in @objectstack/spec 17 (ADR-0049) — the board forwarded it, but the per-column affordance is gated on both `quickAdd` and `onQuickAdd`, and `onQuickAdd` is a host-supplied function JSON cannot carry and no producer ever put on an `object-kanban` node, so authoring it was a parse-clean no-op. Delete the key. The quick-add control is unchanged on the `kanban-ui` block, where a React host supplies the `onQuickAdd` slot the control needs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
825+
| **quickAdd** | `never` | optional | [REMOVED] `object-kanban` property `quickAdd` was removed in @objectstack/spec 17 (ADR-0049) — the board forwarded it, but the per-column affordance is gated on both `quickAdd` and `onQuickAdd`, and `onQuickAdd` is a host-supplied function JSON cannot carry and no producer ever put on an `object-kanban` node, so authoring it was a parse-clean no-op. Delete the key; `object-kanban` offers no quick-add control. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
826826
| **coverImageField** | `string` | optional | Image field rendered as the card cover |
827827
| **conditionalFormatting** | `any` | optional | Card conditional formatting rules |
828828
| **navigation** | `{ mode: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation: boolean; openNewTab: boolean; size: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Card-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`). The renderer's own default is `{ mode: 'drawer' }` when the key is absent; it is documented rather than declared, so a parsed board carries the key only when the author wrote it |

‎docs/adr/0006-project-environment-split.v4.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ when needed.
240240
2. **Visibility default for CLI-created packages.** `private` (owner org
241241
only) — consistent with `sys_package.visibility` default. Explicit
242242
`objectstack package publish --marketplace` flow to escalate.
243+
**Resolved (2026-09-30, #20892): `org`.** A new package is visible to every environment of its owner organization by default, not `private`. Re-publishing without `--visibility` leaves an existing package's visibility unchanged, and listing on the public marketplace remains an explicit choice.
243244
3. **Multi-package envs in Studio.** The current "Environment detail" page
244245
assumes one artifact. Phase B+ updates it to list installations and
245246
show per-package versions.

‎packages/core/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export * from './utils/datetime.js';
5656
// runtime dependencies, and a copy per face is how one `sum` came to answer
5757
// two doubles.
5858
export * from './utils/compensated-sum.js';
59+
export * from './utils/json-membership-sql.js';
5960
// [#20889] What an aggregate ANSWERS and its `'number'` presenter, moved from
6061
// `driver-sql` so the analytics native-SQL face presents with the same rule.
6162
export * from './utils/aggregate-answer.js';
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20987] `jsonMembershipCandidates` and `jsonMembershipPredicate` — the one
5+
* `$contains` membership construct `driver-sql` and the analytics read scope
6+
* and `where` ask, moved here from `driver-sql` (#17590).
7+
*
8+
* What each face does with the SQL is pinned in its own package (the driver's
9+
* move proof `sql-driver-20987-json-membership-move.test.ts`, its executed
10+
* three-dialect suite `sql-driver-17590-json-column-membership.test.ts`, and
11+
* the analytics `contains-membership.test.ts`). This file pins the function's
12+
* own contract: the candidate set, the construct per dialect, and that the
13+
* emitters are called left to right, in placeholder order, or not at all.
14+
*/
15+
16+
import { describe, it, expect } from 'vitest';
17+
import { jsonMembershipCandidates, jsonMembershipPredicate, type JsonMembershipEmitters } from './json-membership-sql';
18+
19+
/** Emitters that render `C` for the column and `?` for a value, recording each call in order. */
20+
function recording(): { emit: JsonMembershipEmitters; calls: string[] } {
21+
const calls: string[] = [];
22+
return {
23+
calls,
24+
emit: {
25+
column: () => {
26+
calls.push('column');
27+
return 'C';
28+
},
29+
value: (v) => {
30+
calls.push(`value ${v}`);
31+
return '?';
32+
},
33+
},
34+
};
35+
}
36+
37+
describe('[#20987] jsonMembershipCandidates — the JSON scalars one comparand denotes', () => {
38+
it('a plain string denotes one JSON string', () => {
39+
expect(jsonMembershipCandidates('u1')).toEqual(['"u1"']);
40+
});
41+
42+
it('a string JSON must escape is escaped', () => {
43+
expect(jsonMembershipCandidates('say "hi"')).toEqual(['"say \\"hi\\""']);
44+
});
45+
46+
it('true / false / null also denote their JSON literal', () => {
47+
expect(jsonMembershipCandidates('true')).toEqual(['"true"', 'true']);
48+
expect(jsonMembershipCandidates('false')).toEqual(['"false"', 'false']);
49+
expect(jsonMembershipCandidates('null')).toEqual(['"null"', 'null']);
50+
});
51+
52+
it('a JSON number also denotes the number, canonicalised', () => {
53+
expect(jsonMembershipCandidates('7')).toEqual(['"7"', '7']);
54+
expect(jsonMembershipCandidates('1.50')).toEqual(['"1.50"', '1.5']);
55+
expect(jsonMembershipCandidates(10)).toEqual(['"10"', '10']);
56+
});
57+
58+
it('a string Number() accepts but the JSON number grammar refuses denotes the string only', () => {
59+
for (const text of ['0x10', ' 1 ', 'Infinity', '', '01', '1.']) {
60+
expect(jsonMembershipCandidates(text), JSON.stringify(text)).toEqual([JSON.stringify(text)]);
61+
}
62+
});
63+
});
64+
65+
describe('[#20987] jsonMembershipPredicate — the construct per dialect, through the caller\'s emitters', () => {
66+
it('sqlite: a json_each element scan, the column twice then the value, per candidate', () => {
67+
const { emit, calls } = recording();
68+
expect(jsonMembershipPredicate('sqlite', emit, 'u1')).toBe(
69+
"(EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(C) THEN C ELSE '[]' END) AS os_member "
70+
+ "WHERE typeof(os_member.key) = 'integer' AND CASE os_member.type "
71+
+ "WHEN 'true' THEN 'true' WHEN 'false' THEN 'false' WHEN 'null' THEN 'null' "
72+
+ 'ELSE json_quote(os_member.value) END = ?))',
73+
);
74+
expect(calls).toEqual(['column', 'column', 'value "u1"']);
75+
});
76+
77+
it('postgres: jsonb containment of the array-wrapped candidate', () => {
78+
const { emit, calls } = recording();
79+
expect(jsonMembershipPredicate('postgres', emit, 'u1')).toBe('(C::jsonb @> ?::jsonb)');
80+
expect(calls).toEqual(['column', 'value ["u1"]']);
81+
});
82+
83+
it('mysql: JSON_CONTAINS of the array-wrapped candidate', () => {
84+
const { emit, calls } = recording();
85+
expect(jsonMembershipPredicate('mysql', emit, 'u1')).toBe('(JSON_CONTAINS(C, ?))');
86+
expect(calls).toEqual(['column', 'value ["u1"]']);
87+
});
88+
89+
it('two candidates are OR-ed inside one pair of parentheses, emitted left to right', () => {
90+
const { emit, calls } = recording();
91+
expect(jsonMembershipPredicate('postgres', emit, '1.50')).toBe('(C::jsonb @> ?::jsonb OR C::jsonb @> ?::jsonb)');
92+
expect(calls).toEqual(['column', 'value ["1.50"]', 'column', 'value [1.5]']);
93+
});
94+
95+
it("'unknown' answers null and calls neither emitter", () => {
96+
const { emit, calls } = recording();
97+
expect(jsonMembershipPredicate('unknown', emit, 'true')).toBeNull();
98+
expect(calls).toEqual([]);
99+
});
100+
101+
it('a positional caller binds in placeholder order', () => {
102+
const params: unknown[] = [];
103+
const sql = jsonMembershipPredicate(
104+
'postgres',
105+
{ column: () => '"t"."tags"', value: (v) => { params.push(v); return `$${params.length}`; } },
106+
'true',
107+
);
108+
expect(sql).toBe('("t"."tags"::jsonb @> $1::jsonb OR "t"."tags"::jsonb @> $2::jsonb)');
109+
expect(params).toEqual(['["true"]', '[true]']);
110+
});
111+
});

0 commit comments

Comments
 (0)