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);
+  });
+});

Reply via email to