This is an automated email from the ASF dual-hosted git repository.
voidmatcha pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new f9754f98e1 [ZEPPELIN-6736] Implement Shared Notebook Core read state
and selectors
f9754f98e1 is described below
commit f9754f98e1c9791c5938e0650eef9a0fb46af2b6
Author: 김예나 <[email protected]>
AuthorDate: Sun Oct 11 13:28:01 2026 +0900
[ZEPPELIN-6736] Implement Shared Notebook Core read state and selectors
### What is this PR for?
Implement the read side of the Shared Notebook Core: plain TypeScript
state, a store with cached immutable snapshots, and selectors. Everything is in
`projects/zeppelin-notebook-core`, with no SDK, REST, WebSocket, RxJS or
framework import. The existing boundary check enforces that: the Core may
import only its own sources and `tslib`. Connecting a host transport and an
Angular consumer is ZEPPELIN-6737.
Commits:
1. **Read inputs and read state** (`read-model.ts`):
* Core-owned input types for the NOTE note, NOTE_REVISION and the note
permissions REST response. The Core cannot import SDK types, so these copy the
shapes it reads.
* `test/notebook-core/read-input-compat.spec.ts` checks at compile time
that the SDK `Note` / `NoteRevision` and the app `Permissions` types still pass
into them unchanged.
* The read snapshot extends `NotebookCoreSnapshot`, so a read store can
be handed to a remote as its `NotebookPort`.
2. **Read store** (`read-store.ts`):
* `activate(target)` returns a request handle; the host passes each
response through it.
* Snapshots are deeply frozen copies of the input. Unchanged branches
keep their identity.
* Notifications are deterministic, and `dispose` cleans up.
3. **Selectors** (`read-selectors.ts`): ordered paragraphs, a paragraph and
its persisted chart settings, and note capabilities.
4. **Review fixes:**
* Read-state keys are defined as own properties, so a special wire key
such as an imported `__proto__` paragraph ID stays a property instead of
setting the prototype.
* A `NOTE_REVISION` without a note reports `failed`.
#### Read state
* **Target:** `{ kind: 'note', noteId }` or `{ kind: 'revision', noteId,
revisionId }`. A live note and a revision of it are distinct targets.
* **Status:** `idle`, `loading`, `ready`, `not-found`, `access-denied`,
`failed`, `disposed`.
* `not-found` and `access-denied` are reported only when the host can
tell them apart.
* A failure ends the request, and a failure after `ready` is rejected.
* **Note:** `id`, `name`, `path`, `paragraphIds` (order) and
`paragraphsById`. Each paragraph holds `id`, `title`, `text`, `status`,
`resultCode`, `resultMessages` and `chartConfigs` (persisted `graph` settings
by result index, kept uninterpreted).
* **Permissions:** `unknown`, `loading`, `ready`, `access-denied`,
`failed`, plus the four lists.
* **Capabilities** (`selectAclCapabilities`) follow
`AuthorizationService`'s list rules:
* an empty list admits everyone;
* owners can write, writers can run, runners can read;
* nothing is granted until the lists are `ready`.
The server's admin role and anonymous mode are not visible to the Core.
So this can deny what the server allows, but never allows what the lists deny.
#### Read operation inventory
| Operation | Core input | Included |
| --- | --- | --- |
| `GET_NOTE` → `NOTE` (full note) | `request.acceptNote(note)` | yes |
| `NOTE_REVISION` (full revision) | `request.acceptRevision(revision)`; a
response without a note is `failed`, because `Notebook#getNoteByRevision`
returns none both for a missing revision and when the default repository does
not support revisions | yes |
| `GET /api/notebook/{id}/permissions` | `request.readAcl()`, then
`accept(lists)` or `fail(reason)` | yes |
| `ERROR_INFO` / `AUTH_INFO` for a load | `request.fail(reason)`, only
while loading | as an outcome the host attributes |
| `PARAGRAPH`, `PATCH_PARAGRAPH`, `PARAGRAPH_ADDED` / `REMOVED` / `MOVED`,
`NOTE_UPDATED`, progress, streaming output, collaboration, angular objects |
none | **no** |
**Reload policy.** The excluded events are not applied as live updates.
Read state changes only through a full `NOTE` or `NOTE_REVISION`, or a new
`activate()`. A host that needs current state re-activates the target or
delivers a full `NOTE`. When a later full `NOTE` arrives for the same target,
it replaces the loaded note, and unchanged paragraphs keep their identity.
#### Field ownership
* **Server (Core-owned read state):** note identity and name/path,
paragraph order and content, saved results, persisted chart settings, revision
identity, and the permission lists.
* **Host (outside the Core):** authentication and the principal, navigation
and route parameters, server authorization (the admin role and anonymous mode),
the transport and request attribution.
* **Not held:** editor drafts, run/cancel/save state and live execution
output (later sub-tasks of ZEPPELIN-6687).
#### Attribution: what the wire provides and what remains unresolved
Checked against `NotebookServer` and `WebSocketServiceCallback` on `master`:
* **`NOTE` has no `msgId`.** The same op is sent for `GET_NOTE`,
`RELOAD_NOTE` and the note-change broadcast (`inlineBroadcastNote`). On the
wire, a `NOTE` can be attributed only by `note.id`, never to a request.
* **Unresolved:** in A → B → A, a late `NOTE(A)` from the first visit is
indistinguishable from the second visit's response.
* The Core rejects the first visit's handle. But a host that routes
`NOTE` by note id alone would deliver that late `NOTE(A)` through the second
visit's handle.
* As the issue notes, stamping the current generation when the message
arrives does not establish its origin.
* **`NOTE_REVISION` has no `msgId`** but carries `noteId` and `revisionId`.
A repeated request for the same revision is likewise indistinguishable on the
wire. Its content is the same immutable revision, so accepting either response
gives the same state.
* **Load failures:** `ERROR_INFO` (not found or other) and `AUTH_INFO`
(forbidden) carry only an `info` string, with no note id or `msgId`. The op
tells the kind apart, but not which request failed.
* **Permissions:** a REST response is bound to its request, so its handle
attribution is exact.
In the specs, the request handles are a **synthetic request context**, and
the `request handles` describe block says so. They verify the Core's contract
for responses the host has attributed. They are not evidence that the current
wire can attribute them. That is ZEPPELIN-6737's acceptance criterion, as is
any minimal read-transport change it needs.
**Not in this PR:** route wiring, Angular or React rendering, editor
drafts, save/run/cancel commands, structural mutations, incremental output,
collaboration, reconnect recovery, ACL writes and React opt-in. This does not
complete ZEPPELIN-6687.
### What type of PR is it?
Improvement
### Todos
* [x] Core-owned read inputs, with a compile-time SDK compatibility check
* [x] Read store: request handles, frozen snapshots, stable identity,
deterministic notifications, dispose
* [x] Selectors for ordered paragraphs, results, chart settings and
capabilities
* [x] Read operation inventory, field ownership and unresolved attribution
cases (above)
### What is the Jira issue?
[ZEPPELIN-6736](https://issues.apache.org/jira/browse/ZEPPELIN-6736)
### How should this be tested?
```bash
cd zeppelin-web-angular
npm run test:notebook-core # 162 passed (48 new store/selector specs,
1 compat spec)
npm run typecheck:notebook-core # passes (includes the compat spec and the
<at>ts-expect-error mutation checks)
npm run typecheck:react # passes
npm run test:react # passes
npx eslint projects/zeppelin-notebook-core/src test/notebook-core
```
No Angular, React or browser is needed. Each of the first three commits
passes `test:notebook-core`, the Core typecheck and eslint on its own (115 →
145 → 159 tests). With the review fixes, it is 162. On a merge with the current
`master`, it is 166 (including the new specs there) and the typecheck passes.
Covered:
* **Load outcomes:** initial load, `not-found`, `access-denied`, `failed`,
a revision without a note as `failed`, a failure ending the request, and no
failure after `ready`.
* **Targets:** live note ↔ revision transitions, A → B → A, the same
revision requested again, mismatched note or revision ids, and deactivate.
* **Snapshots:**
* the same reference while nothing changes, including for an identical
full `NOTE`;
* paragraph and order identity on partial changes;
* no reference kept to host input;
* `__proto__` as a paragraph ID (looked up and updated by a later full
`NOTE`) and inside chart settings;
* nested mutation throws a `TypeError` (and is rejected by the type
checker), including the permission lists in every permissions state.
* **Subscriptions:** notification order, subscribing and unsubscribing
while notifying, the same listener subscribed twice, and dispose.
* **Permissions:** reads, failures, a replaced read, a read from a replaced
activation, and list identity across re-reads.
**Each spec catches the defect it targets.** I broke the implementation on
purpose and checked that the failures were assertions:
* a handle that ignores its own currency;
* no paragraph reuse;
* no freezing;
* no note id check;
* an empty list treated as closed;
* capabilities granted while permissions are unknown;
* in-place subscription changes;
* shallow-frozen permission lists.
Breaking the compat types fails `typecheck:notebook-core` for all three
payloads.
### Screenshots (if appropriate)
N/A.
### Questions:
* Does the license files need to update? No. New files carry the ASF header.
* Is there breaking changes for older versions? No. The existing contract
types are unchanged; the public API only gains exports.
* Does this needs documentation? The read model is documented in the source
and in this PR.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Closes #5559 from kimyenac/ZEPPELIN-6736.
Signed-off-by: YONGJAE LEE <[email protected]>
---
.../zeppelin-notebook-core/src/public-api.ts | 3 +
.../zeppelin-notebook-core/src/read-model.ts | 116 +++++
.../src/read-selectors.spec.ts | 131 ++++++
.../zeppelin-notebook-core/src/read-selectors.ts | 98 +++++
.../zeppelin-notebook-core/src/read-store.spec.ts | 482 +++++++++++++++++++++
.../zeppelin-notebook-core/src/read-store.ts | 355 +++++++++++++++
.../test/notebook-core/read-input-compat.spec.ts | 31 ++
7 files changed, 1216 insertions(+)
diff --git
a/zeppelin-web-angular/projects/zeppelin-notebook-core/src/public-api.ts
b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/public-api.ts
index 0d82f21dde..6f54cf0821 100644
--- a/zeppelin-web-angular/projects/zeppelin-notebook-core/src/public-api.ts
+++ b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/public-api.ts
@@ -11,3 +11,6 @@
*/
export * from './host-remote-contract';
+export * from './read-model';
+export * from './read-selectors';
+export * from './read-store';
diff --git
a/zeppelin-web-angular/projects/zeppelin-notebook-core/src/read-model.ts
b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/read-model.ts
new file mode 100644
index 0000000000..5ec53e9f96
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/read-model.ts
@@ -0,0 +1,116 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { NotebookCoreSnapshot } from './host-remote-contract';
+
+// Inputs: the read payload fields the Core consumes. The Core cannot import
SDK types, so these
+// mirror the wire shapes structurally; the host converts NOTE, NOTE_REVISION
and the permissions
+// REST response into them. Fields the Core does not read are deliberately
absent.
+
+export type NotebookParagraphStatus = 'UNKNOWN' | 'READY' | 'PENDING' |
'RUNNING' | 'FINISHED' | 'ERROR' | 'ABORT';
+
+export type NotebookResultCode = 'SUCCESS' | 'INCOMPLETE' | 'ERROR' |
'KEEP_PREVIOUS_RESULT';
+
+export interface NotebookResultMessageInput {
+ readonly type: string;
+ readonly data: string;
+}
+
+export interface NotebookParagraphInput {
+ readonly id: string;
+ readonly title?: string;
+ readonly text?: string;
+ readonly status: NotebookParagraphStatus;
+ /** Persisted visualization settings, keyed by result index. The Core keeps
`graph` uninterpreted. */
+ readonly config?: Readonly<{ results?: Readonly<{ [resultIndex: string]:
Readonly<{ graph?: unknown }> }> }>;
+ readonly results?: Readonly<{ code?: NotebookResultCode; msg?: readonly
NotebookResultMessageInput[] }>;
+}
+
+/** The `note` of a full NOTE message. */
+export interface NotebookNoteInput {
+ readonly id: string;
+ readonly name: string;
+ readonly path?: string;
+ readonly paragraphs: readonly NotebookParagraphInput[];
+}
+
+/** A full NOTE_REVISION message. */
+export interface NotebookRevisionInput {
+ readonly noteId: string;
+ readonly revisionId: string;
+ readonly note?: NotebookNoteInput;
+}
+
+/** The note permissions REST response. */
+export interface NotebookAclInput {
+ readonly owners: readonly string[];
+ readonly readers: readonly string[];
+ readonly runners: readonly string[];
+ readonly writers: readonly string[];
+}
+
+// Read state: what the Core publishes. Every snapshot is deeply frozen.
+
+/** A live note and a historical revision of it are different targets. */
+export type NotebookReadTarget =
+ Readonly<{ kind: 'note'; noteId: string }> | Readonly<{ kind: 'revision';
noteId: string; revisionId: string }>;
+
+/**
+ * `not-found` and `access-denied` are reported only when the host can tell
them apart;
+ * anything else it cannot load is `failed`.
+ */
+export type NotebookReadStatus = 'idle' | 'loading' | 'ready' | 'not-found' |
'access-denied' | 'failed' | 'disposed';
+
+export type NotebookReadFailure = 'not-found' | 'access-denied' | 'failed';
+
+export type NotebookAclStatus = 'unknown' | 'loading' | 'ready' |
'access-denied' | 'failed';
+
+export type NotebookResultMessage = Readonly<{ type: string; data: string }>;
+
+export type NotebookReadParagraph = Readonly<{
+ id: string;
+ title: string | null;
+ text: string;
+ status: NotebookParagraphStatus;
+ resultCode: NotebookResultCode | null;
+ resultMessages: readonly NotebookResultMessage[];
+ /** Persisted `graph` settings by result index, kept as the server sent
them. */
+ chartConfigs: Readonly<{ [resultIndex: string]: unknown }>;
+}>;
+
+export type NotebookReadNote = Readonly<{
+ id: string;
+ name: string;
+ path: string | null;
+ paragraphIds: readonly string[];
+ paragraphsById: Readonly<{ [paragraphId: string]: NotebookReadParagraph }>;
+}>;
+
+export type NotebookReadAcl = Readonly<{
+ status: NotebookAclStatus;
+ owners: readonly string[];
+ readers: readonly string[];
+ runners: readonly string[];
+ writers: readonly string[];
+}>;
+
+/**
+ * Extends the NotebookPort snapshot, so a read store can be handed to a
remote as its port.
+ * Without a target, `noteId` is empty and `revisionId` is null.
+ */
+export type NotebookReadSnapshot = NotebookCoreSnapshot &
+ Readonly<{
+ target: NotebookReadTarget | null;
+ status: NotebookReadStatus;
+ note: NotebookReadNote | null;
+ acl: NotebookReadAcl;
+ }>;
diff --git
a/zeppelin-web-angular/projects/zeppelin-notebook-core/src/read-selectors.spec.ts
b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/read-selectors.spec.ts
new file mode 100644
index 0000000000..877181ce83
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/read-selectors.spec.ts
@@ -0,0 +1,131 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import type { NotebookAclInput, NotebookNoteInput } from './read-model';
+import { selectAclCapabilities, selectChartConfig, selectOrderedParagraphs,
selectParagraph } from './read-selectors';
+import { createNotebookReadStore } from './read-store';
+
+const note: NotebookNoteInput = {
+ id: 'note-a',
+ name: 'note-a name',
+ paragraphs: [
+ { id: 'p2', status: 'READY' },
+ {
+ id: 'p1',
+ status: 'FINISHED',
+ config: { results: { '0': { graph: { mode: 'lineChart' } }, '1': {} } }
+ }
+ ]
+};
+
+const loadedStore = () => {
+ const store = createNotebookReadStore();
+ const request = store.activate({ kind: 'note', noteId: 'note-a' });
+ request.acceptNote(note);
+ return { store, request };
+};
+
+const withAcl = (acl: NotebookAclInput) => {
+ const { store, request } = loadedStore();
+ request.readAcl().accept(acl);
+ return store.getSnapshot();
+};
+
+describe('notebook read selectors', () => {
+ it('lists paragraphs in note order and reuses the list while the note is
unchanged', () => {
+ const { store, request } = loadedStore();
+ const paragraphs = selectOrderedParagraphs(store.getSnapshot());
+
+ expect(paragraphs.map(paragraph => paragraph.id)).toEqual(['p2', 'p1']);
+ request.readAcl();
+ expect(selectOrderedParagraphs(store.getSnapshot())).toBe(paragraphs);
+ request.acceptNote({ ...note, paragraphs: [...note.paragraphs].reverse()
});
+ expect(selectOrderedParagraphs(store.getSnapshot()).map(paragraph =>
paragraph.id)).toEqual(['p1', 'p2']);
+ });
+
+ it('lists no paragraphs before a note loads', () => {
+ const store = createNotebookReadStore();
+ store.activate({ kind: 'note', noteId: 'note-a' });
+
+ expect(selectOrderedParagraphs(store.getSnapshot())).toEqual([]);
+ expect(selectOrderedParagraphs(store.getSnapshot())).toBe(
+ selectOrderedParagraphs(createNotebookReadStore().getSnapshot())
+ );
+ });
+
+ it('finds a paragraph and its persisted chart settings by id, ignoring
inherited keys', () => {
+ const snapshot = loadedStore().store.getSnapshot();
+
+ expect(selectParagraph(snapshot, 'p1')?.status).toBe('FINISHED');
+ expect(selectParagraph(snapshot, 'missing')).toBeUndefined();
+ expect(selectParagraph(snapshot, 'constructor')).toBeUndefined();
+ expect(selectChartConfig(snapshot, 'p1', 0)).toEqual({ mode: 'lineChart'
});
+ expect(selectChartConfig(snapshot, 'p1', 1)).toBeUndefined();
+ expect(selectChartConfig(snapshot, 'p2', 0)).toBeUndefined();
+ });
+
+ it('selects a paragraph whose ID is __proto__', () => {
+ const store = createNotebookReadStore();
+ store
+ .activate({ kind: 'note', noteId: 'note-a' })
+ .acceptNote({ id: 'note-a', name: 'note-a name', paragraphs: [{ id:
'__proto__', status: 'READY' }] });
+ const snapshot = store.getSnapshot();
+
+ expect(selectParagraph(snapshot, '__proto__')?.id).toBe('__proto__');
+ expect(selectOrderedParagraphs(snapshot).map(paragraph =>
paragraph.id)).toEqual(['__proto__']);
+ });
+
+ it.each(['unknown', 'loading', 'access-denied', 'failed'])('grants nothing
while permissions are %s', status => {
+ const { store, request } = loadedStore();
+ if (status !== 'unknown') {
+ const aclRequest = request.readAcl();
+ if (status !== 'loading') {
+ aclRequest.fail(status as 'access-denied' | 'failed');
+ }
+ }
+
+ expect(store.getSnapshot().acl.status).toBe(status);
+ expect(selectAclCapabilities(store.getSnapshot(), ['alice'])).toEqual({
+ read: false,
+ run: false,
+ write: false,
+ own: false
+ });
+ });
+
+ it.each([
+ ['an owner', ['alice'], { read: true, run: true, write: true, own: true }],
+ ['a writer', ['carol'], { read: true, run: true, write: true, own: false
}],
+ ['a runner through a role', ['dave', 'analysts'], { read: true, run: true,
write: false, own: false }],
+ ['a reader', ['bob'], { read: true, run: false, write: false, own: false
}],
+ ['anyone else', ['mallory'], { read: false, run: false, write: false, own:
false }]
+ ])('grants %s the capabilities the server lists allow', (_name, principals,
expected) => {
+ const snapshot = withAcl({ owners: ['alice'], writers: ['carol'], runners:
['analysts'], readers: ['bob'] });
+
+ expect(selectAclCapabilities(snapshot, principals)).toEqual(expected);
+ });
+
+ it('treats an empty list as open to everyone, as the server does', () => {
+ const snapshot = withAcl({ owners: ['alice'], writers: ['carol'], runners:
['dave'], readers: [] });
+
+ expect(selectAclCapabilities(snapshot, ['mallory'])).toEqual({ read: true,
run: false, write: false, own: false });
+ });
+
+ it('returns the same capabilities object for the same result', () => {
+ const snapshot = withAcl({ owners: ['alice'], writers: [], runners: [],
readers: [] });
+
+ expect(selectAclCapabilities(snapshot,
['alice'])).toBe(selectAclCapabilities(snapshot, ['alice']));
+ expect(Object.isFrozen(selectAclCapabilities(snapshot,
['alice']))).toBe(true);
+ });
+});
diff --git
a/zeppelin-web-angular/projects/zeppelin-notebook-core/src/read-selectors.ts
b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/read-selectors.ts
new file mode 100644
index 0000000000..15c5475d4b
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/read-selectors.ts
@@ -0,0 +1,98 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { NotebookReadNote, NotebookReadParagraph, NotebookReadSnapshot }
from './read-model';
+
+// Every selector returns either a value the snapshot already holds or a
cached one, so an
+// external-store consumer sees the same reference until the underlying state
changes.
+
+const hasOwn = (object: object, key: string): boolean =>
Object.prototype.hasOwnProperty.call(object, key);
+
+const orderedParagraphsByNote = new WeakMap<NotebookReadNote, readonly
NotebookReadParagraph[]>();
+const noParagraphs: readonly NotebookReadParagraph[] = Object.freeze([]);
+
+/** Paragraphs in note order. The array is reused for as long as the note
object is unchanged. */
+export const selectOrderedParagraphs = (snapshot: NotebookReadSnapshot):
readonly NotebookReadParagraph[] => {
+ const note = snapshot.note;
+ if (note === null) {
+ return noParagraphs;
+ }
+ let paragraphs = orderedParagraphsByNote.get(note);
+ if (paragraphs === undefined) {
+ paragraphs = Object.freeze(note.paragraphIds.map(paragraphId =>
note.paragraphsById[paragraphId]));
+ orderedParagraphsByNote.set(note, paragraphs);
+ }
+ return paragraphs;
+};
+
+export const selectParagraph = (
+ snapshot: NotebookReadSnapshot,
+ paragraphId: string
+): NotebookReadParagraph | undefined => {
+ const note = snapshot.note;
+ return note !== null && hasOwn(note.paragraphsById, paragraphId) ?
note.paragraphsById[paragraphId] : undefined;
+};
+
+/** The persisted `graph` settings of one result, uninterpreted. */
+export const selectChartConfig = (
+ snapshot: NotebookReadSnapshot,
+ paragraphId: string,
+ resultIndex: number
+): unknown => {
+ const chartConfigs = selectParagraph(snapshot, paragraphId)?.chartConfigs;
+ const key = String(resultIndex);
+ return chartConfigs !== undefined && hasOwn(chartConfigs, key) ?
chartConfigs[key] : undefined;
+};
+
+export type NotebookAclCapabilities = Readonly<{
+ read: boolean;
+ run: boolean;
+ write: boolean;
+ own: boolean;
+}>;
+
+// One frozen object per combination keeps the result reference stable across
calls.
+const capabilityCombinations = new Map<string, NotebookAclCapabilities>();
+const capabilityFor = (read: boolean, run: boolean, write: boolean, own:
boolean): NotebookAclCapabilities => {
+ const key = `${read}${run}${write}${own}`;
+ let combination = capabilityCombinations.get(key);
+ if (combination === undefined) {
+ combination = Object.freeze({ read, run, write, own });
+ capabilityCombinations.set(key, combination);
+ }
+ return combination;
+};
+
+// As on the server, an empty list admits everyone.
+const isMember = (list: readonly string[], principals: readonly string[]):
boolean =>
+ list.length === 0 || list.some(entity => principals.includes(entity));
+
+/**
+ * What the note permissions grant to the given user and roles, following the
server's list rules:
+ * owners can write, writers can run, and runners can read. Until the lists
are read, nothing is
+ * granted. The server's admin role and anonymous mode are not visible here,
so this can deny what
+ * the server would allow but never allows what the lists deny.
+ */
+export const selectAclCapabilities = (
+ snapshot: NotebookReadSnapshot,
+ principals: readonly string[]
+): NotebookAclCapabilities => {
+ const { acl } = snapshot;
+ if (acl.status !== 'ready') {
+ return capabilityFor(false, false, false, false);
+ }
+ const own = isMember(acl.owners, principals);
+ const write = own || isMember(acl.writers, principals);
+ const run = write || isMember(acl.runners, principals);
+ const read = run || isMember(acl.readers, principals);
+ return capabilityFor(read, run, write, own);
+};
diff --git
a/zeppelin-web-angular/projects/zeppelin-notebook-core/src/read-store.spec.ts
b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/read-store.spec.ts
new file mode 100644
index 0000000000..99055a4e61
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/read-store.spec.ts
@@ -0,0 +1,482 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { assertType, describe, expect, it } from 'vitest';
+
+import type { NotebookCorePort } from './host-remote-contract';
+import type { NotebookNoteInput, NotebookParagraphInput, NotebookReadSnapshot
} from './read-model';
+import { createNotebookReadStore } from './read-store';
+
+const paragraph = (id: string, overrides: Partial<NotebookParagraphInput> =
{}): NotebookParagraphInput => ({
+ id,
+ title: `${id} title`,
+ text: `%md ${id}`,
+ status: 'FINISHED',
+ config: { results: { '0': { graph: { mode: 'table', height: 300 } } } },
+ results: { code: 'SUCCESS', msg: [{ type: 'TEXT', data: `${id} output` }] },
+ ...overrides
+});
+
+const note = (
+ id: string,
+ paragraphs: NotebookParagraphInput[] = [paragraph('p1'), paragraph('p2')]
+): NotebookNoteInput => ({
+ id,
+ name: `${id} name`,
+ path: `/${id}`,
+ paragraphs
+});
+
+const acl = { owners: ['alice'], readers: ['bob'], runners: [], writers:
['carol'] };
+
+const recordNotifications = (store: ReturnType<typeof
createNotebookReadStore>) => {
+ const snapshots: NotebookReadSnapshot[] = [];
+ store.subscribe(() => snapshots.push(store.getSnapshot()));
+ return snapshots;
+};
+
+// assertType does not invoke this callback; tsc checks the rejected mutations.
+assertType<(snapshot: NotebookReadSnapshot) => void>(snapshot => {
+ // @ts-expect-error The read status is readonly.
+ snapshot.status = 'ready';
+ // @ts-expect-error The paragraph order is readonly.
+ snapshot.note?.paragraphIds.push('p3');
+ // @ts-expect-error Paragraph state is readonly.
+ snapshot.note!.paragraphsById.p1.text = 'changed';
+ // @ts-expect-error Permission lists are readonly.
+ snapshot.acl.owners.push('mallory');
+});
+
+// The store's snapshot extends the NotebookPort snapshot, so the store can
serve as a port.
+assertType<NotebookCorePort>(createNotebookReadStore());
+
+describe('notebook read store', () => {
+ it('starts idle with no target and unknown permissions', () => {
+ const snapshot = createNotebookReadStore().getSnapshot();
+
+ expect(snapshot).toEqual({
+ noteId: '',
+ revisionId: null,
+ target: null,
+ status: 'idle',
+ note: null,
+ acl: { status: 'unknown', owners: [], readers: [], runners: [], writers:
[] }
+ });
+ });
+
+ it('loads a live note into ordered paragraphs with saved results and chart
settings', () => {
+ const store = createNotebookReadStore();
+ const request = store.activate({ kind: 'note', noteId: 'note-a' });
+ expect(store.getSnapshot()).toMatchObject({ noteId: 'note-a', revisionId:
null, status: 'loading', note: null });
+
+ expect(request.acceptNote(note('note-a', [paragraph('p2'), paragraph('p1',
{ title: undefined })]))).toBe(true);
+
+ const snapshot = store.getSnapshot();
+ expect(snapshot.status).toBe('ready');
+ expect(snapshot.note).toMatchObject({
+ id: 'note-a',
+ name: 'note-a name',
+ path: '/note-a',
+ paragraphIds: ['p2', 'p1']
+ });
+ expect(snapshot.note?.paragraphsById.p1).toEqual({
+ id: 'p1',
+ title: null,
+ text: '%md p1',
+ status: 'FINISHED',
+ resultCode: 'SUCCESS',
+ resultMessages: [{ type: 'TEXT', data: 'p1 output' }],
+ chartConfigs: { '0': { mode: 'table', height: 300 } }
+ });
+ });
+
+ it.each(['not-found', 'access-denied', 'failed'] as const)('reports a %s
load outcome without a note', reason => {
+ const store = createNotebookReadStore();
+ const request = store.activate({ kind: 'note', noteId: 'note-a' });
+
+ expect(request.fail(reason)).toBe(true);
+
+ expect(store.getSnapshot()).toMatchObject({ noteId: 'note-a', status:
reason, note: null });
+ });
+
+ it('ends a request at its first failure', () => {
+ const store = createNotebookReadStore();
+ const request = store.activate({ kind: 'note', noteId: 'note-a' });
+ request.fail('failed');
+
+ expect(request.acceptNote(note('note-a'))).toBe(false);
+ expect(request.fail('not-found')).toBe(false);
+ expect(store.getSnapshot().status).toBe('failed');
+ });
+
+ it('does not report a failure for a note that has already loaded', () => {
+ const store = createNotebookReadStore();
+ const request = store.activate({ kind: 'note', noteId: 'note-a' });
+ request.acceptNote(note('note-a'));
+
+ expect(request.fail('failed')).toBe(false);
+ expect(store.getSnapshot().status).toBe('ready');
+ });
+
+ describe('snapshot identity and immutability', () => {
+ it('returns the same snapshot until the state changes, and ignores an
identical full note', () => {
+ const store = createNotebookReadStore();
+ const request = store.activate({ kind: 'note', noteId: 'note-a' });
+ request.acceptNote(note('note-a'));
+ const notifications = recordNotifications(store);
+ const snapshot = store.getSnapshot();
+
+ expect(store.getSnapshot()).toBe(snapshot);
+ expect(request.acceptNote(note('note-a'))).toBe(true);
+ expect(store.getSnapshot()).toBe(snapshot);
+ expect(notifications).toEqual([]);
+ });
+
+ it('keeps unchanged paragraphs and order when a full note replaces the
loaded one', () => {
+ const store = createNotebookReadStore();
+ const request = store.activate({ kind: 'note', noteId: 'note-a' });
+ request.acceptNote(note('note-a'));
+ const before = store.getSnapshot().note!;
+
+ request.acceptNote(note('note-a', [paragraph('p1'), paragraph('p2', {
text: '%md changed' })]));
+ const after = store.getSnapshot().note!;
+
+ expect(after).not.toBe(before);
+ expect(after.paragraphIds).toBe(before.paragraphIds);
+ expect(after.paragraphsById.p1).toBe(before.paragraphsById.p1);
+ expect(after.paragraphsById.p2).not.toBe(before.paragraphsById.p2);
+ expect(after.paragraphsById.p2.text).toBe('%md changed');
+ });
+
+ it('keeps paragraph objects when only their order changes', () => {
+ const store = createNotebookReadStore();
+ const request = store.activate({ kind: 'note', noteId: 'note-a' });
+ request.acceptNote(note('note-a'));
+ const before = store.getSnapshot().note!;
+
+ request.acceptNote(note('note-a', [paragraph('p2'), paragraph('p1')]));
+ const after = store.getSnapshot().note!;
+
+ expect(after.paragraphIds).toEqual(['p2', 'p1']);
+ expect(after.paragraphsById.p1).toBe(before.paragraphsById.p1);
+ expect(after.paragraphsById.p2).toBe(before.paragraphsById.p2);
+ });
+
+ it('rejects nested mutation of a published snapshot', () => {
+ const store = createNotebookReadStore();
+ store.activate({ kind: 'note', noteId: 'note-a'
}).acceptNote(note('note-a'));
+ const snapshot = store.getSnapshot() as unknown as {
+ note: {
+ paragraphIds: string[];
+ paragraphsById: { [id: string]: { text: string; chartConfigs: { [i:
string]: { mode: string } } } };
+ };
+ };
+
+ expect(() => {
+ snapshot.note.paragraphIds.push('p3');
+ }).toThrow(TypeError);
+ expect(() => {
+ snapshot.note.paragraphsById.p1.text = 'changed';
+ }).toThrow(TypeError);
+ expect(() => {
+ snapshot.note.paragraphsById.p1.chartConfigs['0'].mode = 'lineChart';
+ }).toThrow(TypeError);
+ });
+
+ it('freezes the permission lists of every permissions state', () => {
+ const store = createNotebookReadStore();
+ const ownersOf = () => store.getSnapshot().acl.owners as string[];
+ expect(() => ownersOf().push('mallory')).toThrow(TypeError);
+
+ const aclRequest = store.activate({ kind: 'note', noteId: 'note-a'
}).readAcl();
+ expect(() => ownersOf().push('mallory')).toThrow(TypeError);
+
+ aclRequest.fail('failed');
+ expect(() => ownersOf().push('mallory')).toThrow(TypeError);
+
+ store.activate({ kind: 'note', noteId: 'note-a' }).readAcl().accept(acl);
+ expect(() => ownersOf().push('mallory')).toThrow(TypeError);
+ });
+
+ // An imported note keeps its paragraph IDs, so wire keys can be special
property names.
+ it('keeps a paragraph whose ID is __proto__ and updates it from a later
full note', () => {
+ const store = createNotebookReadStore();
+ const request = store.activate({ kind: 'note', noteId: 'note-a' });
+ request.acceptNote(note('note-a', [paragraph('__proto__'),
paragraph('p1')]));
+ const before = store.getSnapshot().note!;
+
+ expect(before.paragraphIds).toEqual(['__proto__', 'p1']);
+ expect(Object.prototype.hasOwnProperty.call(before.paragraphsById,
'__proto__')).toBe(true);
+
expect(Object.getPrototypeOf(before.paragraphsById)).toBe(Object.prototype);
+ expect(before.paragraphsById['__proto__'].text).toBe('%md __proto__');
+
+ request.acceptNote(note('note-a', [paragraph('__proto__', { text: '%md
changed' }), paragraph('p1')]));
+ const after = store.getSnapshot().note!;
+
+ expect(after.paragraphsById['__proto__'].text).toBe('%md changed');
+ expect(after.paragraphsById.p1).toBe(before.paragraphsById.p1);
+ });
+
+ it('keeps __proto__ keys inside persisted chart settings', () => {
+ const store = createNotebookReadStore();
+ // JSON.parse creates `__proto__` as an own key, as a wire payload would.
+ const results = JSON.parse('{"__proto__": {"graph": {"mode": "table",
"__proto__": {"height": 1}}}}');
+ store
+ .activate({ kind: 'note', noteId: 'note-a' })
+ .acceptNote(note('note-a', [paragraph('p1', { config: { results }
})]));
+
+ const chartConfigs =
store.getSnapshot().note!.paragraphsById.p1.chartConfigs;
+ expect(Object.keys(chartConfigs)).toEqual(['__proto__']);
+ const graph = chartConfigs['__proto__'] as { [key: string]: unknown };
+ expect(Object.keys(graph)).toEqual(['mode', '__proto__']);
+ expect(graph['__proto__']).toEqual({ height: 1 });
+ });
+
+ it('keeps no reference to the host input', () => {
+ const store = createNotebookReadStore();
+ const input = note('note-a');
+ store.activate({ kind: 'note', noteId: 'note-a' }).acceptNote(input);
+
+ (input.paragraphs[0].config!.results!['0'].graph as { mode: string
}).mode = 'pieChart';
+ (input.paragraphs as NotebookParagraphInput[]).push(paragraph('p3'));
+
+ expect(store.getSnapshot().note?.paragraphIds).toEqual(['p1', 'p2']);
+
expect(store.getSnapshot().note?.paragraphsById.p1.chartConfigs['0']).toEqual({
mode: 'table', height: 300 });
+ expect(Object.isFrozen(input)).toBe(false);
+ });
+ });
+
+ describe('subscriptions', () => {
+ it('notifies each listener once per change, in subscription order', () => {
+ const store = createNotebookReadStore();
+ const calls: string[] = [];
+ store.subscribe(() => calls.push('first'));
+ store.subscribe(() => calls.push('second'));
+
+ store.activate({ kind: 'note', noteId: 'note-a'
}).acceptNote(note('note-a'));
+
+ expect(calls).toEqual(['first', 'second', 'first', 'second']);
+ });
+
+ it('calls a listener subscribed while notifying from the next change', ()
=> {
+ const store = createNotebookReadStore();
+ const calls: string[] = [];
+ let subscribedLate = false;
+ store.subscribe(() => {
+ calls.push('first');
+ if (!subscribedLate) {
+ subscribedLate = true;
+ store.subscribe(() => calls.push('late'));
+ }
+ });
+
+ const request = store.activate({ kind: 'note', noteId: 'note-a' });
+ expect(calls).toEqual(['first']);
+
+ request.acceptNote(note('note-a'));
+ expect(calls).toEqual(['first', 'first', 'late']);
+ });
+
+ it('still calls a listener unsubscribed while notifying, then stops', ()
=> {
+ const store = createNotebookReadStore();
+ const calls: string[] = [];
+ let unsubscribeSecond = () => {};
+ store.subscribe(() => {
+ calls.push('first');
+ unsubscribeSecond();
+ });
+ unsubscribeSecond = store.subscribe(() => calls.push('second'));
+
+ const request = store.activate({ kind: 'note', noteId: 'note-a' });
+ expect(calls).toEqual(['first', 'second']);
+
+ request.acceptNote(note('note-a'));
+ expect(calls).toEqual(['first', 'second', 'first']);
+ });
+
+ it('stops notifying after unsubscribe, one subscription at a time', () => {
+ const store = createNotebookReadStore();
+ let calls = 0;
+ const listener = () => {
+ calls += 1;
+ };
+ const unsubscribeFirst = store.subscribe(listener);
+ const unsubscribeSecond = store.subscribe(listener);
+
+ unsubscribeFirst();
+ store.activate({ kind: 'note', noteId: 'note-a' });
+ expect(calls).toBe(1);
+
+ unsubscribeSecond();
+ store.deactivate();
+ expect(calls).toBe(1);
+ });
+
+ it('publishes a final disposed snapshot, then drops listeners and rejects
every handle', () => {
+ const store = createNotebookReadStore();
+ const request = store.activate({ kind: 'note', noteId: 'note-a' });
+ const aclRequest = request.readAcl();
+ const notifications = recordNotifications(store);
+
+ store.dispose();
+
+ expect(notifications.map(snapshot =>
snapshot.status)).toEqual(['disposed']);
+ expect(request.isCurrent()).toBe(false);
+ expect(request.acceptNote(note('note-a'))).toBe(false);
+ expect(aclRequest.accept(acl)).toBe(false);
+ store.subscribe(() => notifications.push(store.getSnapshot()));
+ store.deactivate();
+ store.dispose();
+ expect(notifications).toHaveLength(1);
+ expect(() => store.activate({ kind: 'note', noteId: 'note-a' })).toThrow(
+ 'Cannot activate a disposed notebook read store'
+ );
+ });
+ });
+
+ // These handles are a synthetic request context: the host creates one when
it sends a request
+ // and must route the response to it. The current wire cannot always do
that. A NOTE carries no
+ // msgId and the same op is also broadcast on note changes, so a host can
attribute it only by
+ // note id. These tests pin the Core's contract for responses the host has
attributed; whether the
+ // host can attribute them is verified with the real transport in
ZEPPELIN-6737.
+ describe('request handles (synthetic request context)', () => {
+ it('rejects a NOTE for another note and a NOTE_REVISION for a live-note
target', () => {
+ const store = createNotebookReadStore();
+ const request = store.activate({ kind: 'note', noteId: 'note-a' });
+
+ expect(request.acceptNote(note('note-b'))).toBe(false);
+ expect(request.acceptRevision({ noteId: 'note-a', revisionId: 'r1',
note: note('note-a') })).toBe(false);
+ expect(store.getSnapshot().status).toBe('loading');
+ });
+
+ it('accepts only the matching NOTE_REVISION for a revision target', () => {
+ const store = createNotebookReadStore();
+ const request = store.activate({ kind: 'revision', noteId: 'note-a',
revisionId: 'r1' });
+
+ expect(request.acceptNote(note('note-a'))).toBe(false);
+ expect(request.acceptRevision({ noteId: 'note-a', revisionId: 'r2',
note: note('note-a') })).toBe(false);
+ expect(request.acceptRevision({ noteId: 'note-a', revisionId: 'r1',
note: note('note-b') })).toBe(false);
+ expect(store.getSnapshot().status).toBe('loading');
+
+ expect(request.acceptRevision({ noteId: 'note-a', revisionId: 'r1',
note: note('note-a') })).toBe(true);
+ expect(store.getSnapshot()).toMatchObject({ noteId: 'note-a',
revisionId: 'r1', status: 'ready' });
+ });
+
+ // Notebook#getNoteByRevision returns no note both for a missing revision
and when the default
+ // repository does not support revisions, so the cause is unknown.
+ it('reports a NOTE_REVISION without a note as failed rather than not
found', () => {
+ const store = createNotebookReadStore();
+ const request = store.activate({ kind: 'revision', noteId: 'note-a',
revisionId: 'r1' });
+
+ expect(request.acceptRevision({ noteId: 'note-a', revisionId: 'r1'
})).toBe(true);
+
+ expect(store.getSnapshot()).toMatchObject({ status: 'failed', note: null
});
+ });
+
+ it('switches between the live note and its revision, rejecting the
replaced request', () => {
+ const store = createNotebookReadStore();
+ const live = store.activate({ kind: 'note', noteId: 'note-a' });
+ live.acceptNote(note('note-a'));
+
+ const revision = store.activate({ kind: 'revision', noteId: 'note-a',
revisionId: 'r1' });
+ expect(store.getSnapshot()).toMatchObject({ revisionId: 'r1', status:
'loading', note: null });
+ expect(live.acceptNote(note('note-a'))).toBe(false);
+ revision.acceptRevision({ noteId: 'note-a', revisionId: 'r1', note:
note('note-a', [paragraph('p1')]) });
+
+ const liveAgain = store.activate({ kind: 'note', noteId: 'note-a' });
+ expect(revision.acceptRevision({ noteId: 'note-a', revisionId: 'r1',
note: note('note-a') })).toBe(false);
+ expect(liveAgain.acceptNote(note('note-a'))).toBe(true);
+ expect(store.getSnapshot()).toMatchObject({ revisionId: null, status:
'ready' });
+ });
+
+ it('rejects the first visit after A -> B -> A, though its NOTE matches the
target again', () => {
+ const store = createNotebookReadStore();
+ const firstVisit = store.activate({ kind: 'note', noteId: 'note-a' });
+ store.activate({ kind: 'note', noteId: 'note-b' });
+ const secondVisit = store.activate({ kind: 'note', noteId: 'note-a' });
+
+ expect(firstVisit.acceptNote(note('note-a',
[paragraph('stale')]))).toBe(false);
+ expect(store.getSnapshot()).toMatchObject({ noteId: 'note-a', status:
'loading' });
+
+ expect(secondVisit.acceptNote(note('note-a'))).toBe(true);
+ expect(store.getSnapshot().note?.paragraphIds).toEqual(['p1', 'p2']);
+ });
+
+ it('rejects the earlier request when the same revision is requested
again', () => {
+ const store = createNotebookReadStore();
+ const target = { kind: 'revision', noteId: 'note-a', revisionId: 'r1' }
as const;
+ const first = store.activate(target);
+ const second = store.activate(target);
+
+ expect(first.acceptRevision({ noteId: 'note-a', revisionId: 'r1', note:
note('note-a') })).toBe(false);
+ expect(second.acceptRevision({ noteId: 'note-a', revisionId: 'r1', note:
note('note-a') })).toBe(true);
+ });
+
+ it('returns to idle on deactivate and rejects the replaced request', () =>
{
+ const store = createNotebookReadStore();
+ const request = store.activate({ kind: 'note', noteId: 'note-a' });
+
+ store.deactivate();
+
+ expect(store.getSnapshot()).toMatchObject({ target: null, noteId: '',
status: 'idle' });
+ expect(request.acceptNote(note('note-a'))).toBe(false);
+ expect(request.readAcl().accept(acl)).toBe(false);
+ });
+ });
+
+ describe('note permissions', () => {
+ it('reads permissions for the current activation', () => {
+ const store = createNotebookReadStore();
+ const aclRequest = store.activate({ kind: 'note', noteId: 'note-a'
}).readAcl();
+ expect(store.getSnapshot().acl.status).toBe('loading');
+
+ expect(aclRequest.accept(acl)).toBe(true);
+
+ expect(store.getSnapshot().acl).toEqual({ status: 'ready', ...acl });
+ });
+
+ it.each(['access-denied', 'failed'] as const)('reports a %s permissions
read without lists', reason => {
+ const store = createNotebookReadStore();
+ const aclRequest = store.activate({ kind: 'note', noteId: 'note-a'
}).readAcl();
+
+ expect(aclRequest.fail(reason)).toBe(true);
+
+ expect(store.getSnapshot().acl).toEqual({ status: reason, owners: [],
readers: [], runners: [], writers: [] });
+ });
+
+ it('rejects a replaced permissions read and one from a replaced
activation', () => {
+ const store = createNotebookReadStore();
+ const request = store.activate({ kind: 'note', noteId: 'note-a' });
+ const firstRead = request.readAcl();
+ const secondRead = request.readAcl();
+
+ expect(firstRead.accept(acl)).toBe(false);
+ expect(secondRead.accept(acl)).toBe(true);
+
+ const nextActivation = store.activate({ kind: 'note', noteId: 'note-a'
});
+ expect(secondRead.accept({ ...acl, owners: ['mallory'] })).toBe(false);
+ expect(nextActivation.isCurrent()).toBe(true);
+ expect(store.getSnapshot().acl.status).toBe('unknown');
+ });
+
+ it('keeps the permissions object when a read returns the same lists', ()
=> {
+ const store = createNotebookReadStore();
+ const request = store.activate({ kind: 'note', noteId: 'note-a' });
+ request.readAcl().accept(acl);
+ const first = store.getSnapshot().acl;
+
+ request.readAcl().accept({ ...acl });
+
+ expect(store.getSnapshot().acl).toBe(first);
+ });
+ });
+});
diff --git
a/zeppelin-web-angular/projects/zeppelin-notebook-core/src/read-store.ts
b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/read-store.ts
new file mode 100644
index 0000000000..506e7367e4
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/read-store.ts
@@ -0,0 +1,355 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type {
+ NotebookAclInput,
+ NotebookAclStatus,
+ NotebookNoteInput,
+ NotebookParagraphInput,
+ NotebookReadAcl,
+ NotebookReadFailure,
+ NotebookReadNote,
+ NotebookReadParagraph,
+ NotebookReadSnapshot,
+ NotebookReadStatus,
+ NotebookReadTarget,
+ NotebookRevisionInput
+} from './read-model';
+
+/**
+ * The lifetime of one permissions read. Responses are accepted only while
this is the latest
+ * permissions read of the current activation.
+ */
+export interface NotebookAclRequest {
+ isCurrent(): boolean;
+ accept(acl: NotebookAclInput): boolean;
+ fail(reason: 'access-denied' | 'failed'): boolean;
+}
+
+/**
+ * The lifetime of one activation. The host keeps this handle with the request
it sends and passes
+ * the response through it. Once another activation, a deactivation or dispose
replaces it, every
+ * method rejects, including for the same target visited again.
+ *
+ * Each method returns whether the input was applied.
+ */
+export interface NotebookReadRequest {
+ readonly target: NotebookReadTarget;
+ isCurrent(): boolean;
+ /** A full NOTE for a live-note target. Accepted while loading or ready. */
+ acceptNote(note: NotebookNoteInput): boolean;
+ /**
+ * A full NOTE_REVISION for a revision target. A response without a note is
`failed`: the server
+ * returns none both for a missing revision and when its repository does not
support revisions.
+ */
+ acceptRevision(revision: NotebookRevisionInput): boolean;
+ /** Ends a load that has not completed. A failure ends this request;
activate again to retry. */
+ fail(reason: NotebookReadFailure): boolean;
+ /** Starts a permissions read. A later call replaces an earlier one. */
+ readAcl(): NotebookAclRequest;
+}
+
+export interface NotebookReadStore {
+ /** Returns the same reference until the read state changes. */
+ getSnapshot(): NotebookReadSnapshot;
+ /** Listeners run in subscription order, once per snapshot change. */
+ subscribe(listener: () => void): () => void;
+ activate(target: NotebookReadTarget): NotebookReadRequest;
+ deactivate(): void;
+ /** Publishes a final `disposed` snapshot, then drops every listener. */
+ dispose(): void;
+}
+
+// Wire keys become own properties even when they are special names such as
`__proto__`: an imported
+// note keeps its paragraph IDs, and plain assignment of that key would set
the prototype instead.
+const setOwn = (object: { [key: string]: unknown }, key: string, value:
unknown): void => {
+ Object.defineProperty(object, key, { value, enumerable: true, writable:
true, configurable: true });
+};
+
+// Read state is built from wire data: null, booleans, numbers, strings,
arrays and plain objects.
+const freezeCopy = (value: unknown): unknown => {
+ if (Array.isArray(value)) {
+ return Object.freeze(value.map(freezeCopy));
+ }
+ if (value !== null && typeof value === 'object') {
+ const copy: { [key: string]: unknown } = {};
+ for (const key of Object.keys(value)) {
+ setOwn(copy, key, freezeCopy((value as { [key: string]: unknown
})[key]));
+ }
+ return Object.freeze(copy);
+ }
+ return value;
+};
+
+const sameValue = (a: unknown, b: unknown): boolean => {
+ if (a === b) {
+ return true;
+ }
+ if (Array.isArray(a) && Array.isArray(b)) {
+ return a.length === b.length && a.every((item, index) => sameValue(item,
b[index]));
+ }
+ if (a !== null && b !== null && typeof a === 'object' && typeof b ===
'object') {
+ if (Array.isArray(a) || Array.isArray(b)) {
+ return false;
+ }
+ const aKeys = Object.keys(a);
+ const bKeys = Object.keys(b);
+ return (
+ aKeys.length === bKeys.length &&
+ aKeys.every(
+ key =>
+ Object.prototype.hasOwnProperty.call(b, key) &&
+ sameValue((a as { [key: string]: unknown })[key], (b as { [key:
string]: unknown })[key])
+ )
+ );
+ }
+ return false;
+};
+
+/** Keeps the previous object when the next one has the same content. */
+const reuse = <T>(previous: T | undefined, next: T): T =>
+ previous !== undefined && sameValue(previous, next) ? previous : next;
+
+const emptyAcl = (status: NotebookAclStatus): NotebookReadAcl =>
+ freezeCopy({ status, owners: [], readers: [], runners: [], writers: [] }) as
NotebookReadAcl;
+
+const unknownAcl = emptyAcl('unknown');
+const loadingAcl = emptyAcl('loading');
+
+const buildParagraph = (input: NotebookParagraphInput): NotebookReadParagraph
=> {
+ const chartConfigs: { [resultIndex: string]: unknown } = {};
+ const configResults = input.config?.results ?? {};
+ for (const resultIndex of Object.keys(configResults)) {
+ const graph = configResults[resultIndex]?.graph;
+ if (graph !== undefined) {
+ setOwn(chartConfigs, resultIndex, graph);
+ }
+ }
+ return freezeCopy({
+ id: input.id,
+ title: input.title ?? null,
+ text: input.text ?? '',
+ status: input.status,
+ resultCode: input.results?.code ?? null,
+ resultMessages: (input.results?.msg ?? []).map(message => ({ type:
message.type, data: message.data })),
+ chartConfigs
+ }) as NotebookReadParagraph;
+};
+
+const buildNote = (input: NotebookNoteInput, previous: NotebookReadNote |
null): NotebookReadNote => {
+ const paragraphsById: { [paragraphId: string]: NotebookReadParagraph } = {};
+ for (const paragraphInput of input.paragraphs) {
+ const previousParagraph =
+ previous &&
Object.prototype.hasOwnProperty.call(previous.paragraphsById, paragraphInput.id)
+ ? previous.paragraphsById[paragraphInput.id]
+ : undefined;
+ setOwn(paragraphsById, paragraphInput.id, reuse(previousParagraph,
buildParagraph(paragraphInput)));
+ }
+ const next: NotebookReadNote = Object.freeze({
+ id: input.id,
+ name: input.name,
+ path: input.path ?? null,
+ paragraphIds: reuse(previous?.paragraphIds,
Object.freeze(input.paragraphs.map(paragraph => paragraph.id))),
+ paragraphsById: Object.freeze(paragraphsById)
+ });
+ return reuse(previous ?? undefined, next);
+};
+
+const makeSnapshot = (
+ target: NotebookReadTarget | null,
+ status: NotebookReadStatus,
+ note: NotebookReadNote | null,
+ acl: NotebookReadAcl
+): NotebookReadSnapshot =>
+ Object.freeze({
+ noteId: target?.noteId ?? '',
+ revisionId: target?.kind === 'revision' ? target.revisionId : null,
+ target,
+ status,
+ note,
+ acl
+ });
+
+const idleSnapshot = makeSnapshot(null, 'idle', null, unknownAcl);
+const disposedSnapshot = makeSnapshot(null, 'disposed', null, unknownAcl);
+
+const staleAclRequest: NotebookAclRequest = Object.freeze({
+ isCurrent: () => false,
+ accept: () => false,
+ fail: () => false
+});
+
+export const createNotebookReadStore = (): NotebookReadStore => {
+ let snapshot = idleSnapshot;
+ // One entry per subscription, so subscribing the same function twice needs
two unsubscribes.
+ let subscriptions: Array<{ listener: () => void }> = [];
+ // Identity tokens for the current activation and permissions read. A handle
compares its own
+ // token with these, so a replaced handle stays rejected even when the same
target returns.
+ let activeRequest: object | null = null;
+ let activeAclRequest: object | null = null;
+ let disposed = false;
+
+ const publish = (next: NotebookReadSnapshot): void => {
+ if (next === snapshot) {
+ return;
+ }
+ snapshot = next;
+ // subscribe and unsubscribe replace the array, so changes made while
notifying take effect
+ // from the next change.
+ for (const subscription of subscriptions) {
+ subscription.listener();
+ }
+ };
+
+ // Changes the read state of the current target; activation publishes a new
target directly.
+ const update = (changes: Partial<Pick<NotebookReadSnapshot, 'status' |
'note' | 'acl'>>): void => {
+ const status = changes.status ?? snapshot.status;
+ const note = 'note' in changes ? (changes.note ?? null) : snapshot.note;
+ const acl = changes.acl ?? snapshot.acl;
+ if (status === snapshot.status && note === snapshot.note && acl ===
snapshot.acl) {
+ return;
+ }
+ publish(makeSnapshot(snapshot.target, status, note, acl));
+ };
+
+ const activate = (requestedTarget: NotebookReadTarget): NotebookReadRequest
=> {
+ if (disposed) {
+ throw new Error('Cannot activate a disposed notebook read store');
+ }
+ const token = {};
+ activeRequest = token;
+ activeAclRequest = null;
+ const target = freezeCopy(requestedTarget) as NotebookReadTarget;
+ publish(makeSnapshot(target, 'loading', null, unknownAcl));
+
+ const isCurrent = (): boolean => activeRequest === token;
+ const canLoad = (): boolean => isCurrent() && (snapshot.status ===
'loading' || snapshot.status === 'ready');
+
+ // A permissions re-read passes through `loading`; reuse the last lists
read in this activation.
+ let lastReadyAcl: NotebookReadAcl | undefined;
+
+ const acceptFullNote = (input: NotebookNoteInput): void => {
+ const previous = snapshot.status === 'ready' ? snapshot.note : null;
+ update({ status: 'ready', note: buildNote(input, previous) });
+ };
+
+ const readAcl = (): NotebookAclRequest => {
+ if (!isCurrent()) {
+ return staleAclRequest;
+ }
+ const aclToken = {};
+ activeAclRequest = aclToken;
+ update({ acl: loadingAcl });
+ const isAclCurrent = (): boolean => isCurrent() && activeAclRequest ===
aclToken;
+ return Object.freeze({
+ isCurrent: isAclCurrent,
+ accept: (input: NotebookAclInput): boolean => {
+ if (!isAclCurrent()) {
+ return false;
+ }
+ const acl = freezeCopy({
+ status: 'ready',
+ owners: input.owners,
+ readers: input.readers,
+ runners: input.runners,
+ writers: input.writers
+ }) as NotebookReadAcl;
+ lastReadyAcl = reuse(lastReadyAcl, acl);
+ update({ acl: lastReadyAcl });
+ return true;
+ },
+ fail: (reason: 'access-denied' | 'failed'): boolean => {
+ if (!isAclCurrent()) {
+ return false;
+ }
+ update({ acl: emptyAcl(reason) });
+ return true;
+ }
+ });
+ };
+
+ return Object.freeze({
+ target,
+ isCurrent,
+ acceptNote: (input: NotebookNoteInput): boolean => {
+ if (!canLoad() || target.kind !== 'note' || input.id !==
target.noteId) {
+ return false;
+ }
+ acceptFullNote(input);
+ return true;
+ },
+ acceptRevision: (input: NotebookRevisionInput): boolean => {
+ if (
+ !canLoad() ||
+ target.kind !== 'revision' ||
+ input.noteId !== target.noteId ||
+ input.revisionId !== target.revisionId
+ ) {
+ return false;
+ }
+ if (input.note === undefined || input.note === null) {
+ if (snapshot.status !== 'loading') {
+ return false;
+ }
+ update({ status: 'failed', note: null });
+ return true;
+ }
+ if (input.note.id !== target.noteId) {
+ return false;
+ }
+ acceptFullNote(input.note);
+ return true;
+ },
+ fail: (reason: NotebookReadFailure): boolean => {
+ if (!isCurrent() || snapshot.status !== 'loading') {
+ return false;
+ }
+ update({ status: reason, note: null });
+ return true;
+ },
+ readAcl
+ });
+ };
+
+ return Object.freeze({
+ getSnapshot: () => snapshot,
+ subscribe: (listener: () => void) => {
+ if (disposed) {
+ return () => undefined;
+ }
+ const subscription = { listener };
+ subscriptions = [...subscriptions, subscription];
+ return () => {
+ subscriptions = subscriptions.filter(registered => registered !==
subscription);
+ };
+ },
+ activate,
+ deactivate: () => {
+ if (disposed) {
+ return;
+ }
+ activeRequest = null;
+ activeAclRequest = null;
+ publish(idleSnapshot);
+ },
+ dispose: () => {
+ if (disposed) {
+ return;
+ }
+ disposed = true;
+ activeRequest = null;
+ activeAclRequest = null;
+ publish(disposedSnapshot);
+ subscriptions = [];
+ }
+ });
+};
diff --git a/zeppelin-web-angular/test/notebook-core/read-input-compat.spec.ts
b/zeppelin-web-angular/test/notebook-core/read-input-compat.spec.ts
new file mode 100644
index 0000000000..0fadd360a6
--- /dev/null
+++ b/zeppelin-web-angular/test/notebook-core/read-input-compat.spec.ts
@@ -0,0 +1,31 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { assertType, describe, it } from 'vitest';
+
+import type {
+ NotebookAclInput,
+ NotebookNoteInput,
+ NotebookRevisionInput
+} from '../../projects/zeppelin-notebook-core/src/public-api';
+import type { Note, NoteRevision } from
'../../projects/zeppelin-sdk/src/interfaces/message-notebook.interface';
+import type { Permissions } from '../../src/app/interfaces/security';
+
+// The Core cannot import SDK types, so its read inputs mirror the wire
shapes. If an SDK payload
+// type drifts from them, the host could no longer pass it through unchanged
and tsc fails here.
+describe('notebook core read inputs', () => {
+ it('accept the SDK NOTE, NOTE_REVISION and note permissions payloads
unchanged', () => {
+ assertType<(note: NonNullable<Note['note']>) => NotebookNoteInput>(note =>
note);
+ assertType<(revision: NoteRevision) => NotebookRevisionInput>(revision =>
revision);
+ assertType<(permissions: Permissions) => NotebookAclInput>(permissions =>
permissions);
+ });
+});