Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/project-templates-first-class.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@milaboratories/pl-middle-layer": minor
---

Make project templates first-class entities.

A template is now stored in the user's root rather than only exported to a file: the
immutable `template-v1` document lives in an ephemeral resource's data blob, its label and
timestamps in KV, and the whole shelf is listed and watched like the project list.
`saveProjectAsTemplate`, `renameTemplate`, `deleteTemplate`, `getTemplateData`,
`resolveTemplate`, `createProjectFromTemplate` and `shareTemplate` are the new surface.

A template can also travel: `EnvelopePayload` is now a discriminated union
(`{ kind: "projects" }` | `{ kind: "template" }`) and `EnvelopeData.schemaVersion` is 2.
A template share is read-only and writes no `acceptance/{login}` receipt — the recipient
gets the document on their own shelf and decides when to apply it, so there is no
acceptance to report back. A client that does not recognise a payload kind hides that
share rather than mis-rendering it.
9 changes: 9 additions & 0 deletions lib/node/pl-middle-layer/src/middle_layer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,12 @@ export * from "./driver_kit";
export * from "./ops";
export { ProjectsField } from "./project_list";
export type { OutgoingShare, PendingShare } from "./sharing_list";
export { TemplatesField } from "./template_list";
export type {
CreateProjectFromTemplateOutcome,
SaveProjectAsTemplateOutcome,
ShareTemplateOutcome,
StoredTemplateData,
TemplateId,
TemplateListEntry,
} from "./template_list";
485 changes: 449 additions & 36 deletions lib/node/pl-middle-layer/src/middle_layer/middle_layer.ts

Large diffs are not rendered by default.

44 changes: 37 additions & 7 deletions lib/node/pl-middle-layer/src/middle_layer/sharing_list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@ import type {
EnvelopeAcceptance,
EnvelopeData,
EnvelopeMode,
EnvelopePayloadKind,
ShareId,
} from "../model/sharing_model";
import {
AcceptanceFieldPrefix,
asShareId,
envelopeProjectMap,
normalizeEnvelopeData,
SharedEnvelopeResourceType,
SharingOutboxResourceType,
SharingStateResourceType,
Expand All @@ -32,19 +35,28 @@ export interface OutgoingShare {
expiresAt?: number; // EnvelopeData.expiresAt; null maps to undefined = never expires
mode: EnvelopeMode;
title: string; // display name shown to recipients; defaults to the first project's name
/** What the share carries — a pack of projects, or one template document. */
payloadKind: EnvelopePayloadKind;
/** One entry per project in the pack, so the change UI can offer a per-project decision.
* `projectId` is the donor's source project id; `updatedAt` is when this project's
* snapshot was last (re)taken. */
* snapshot was last (re)taken. Empty for a template share. */
projects: { projectId: ProjectId; label: string; updatedAt: number }[];
/** The shared template, for a template share; absent for a project share. */
template?: { label: string; blockCount: number };
/** Full recipient logins, from `ListGrants` on the envelope; `["*"]` for everyone-shares. */
recipients: string[];
/** Whether {@link responses} can ever be populated for this share. A template share is granted
* read-only, so no recipient can record a reply on the envelope and the donor never learns who
* accepted — a view must say so rather than render an empty response list as "nobody yet". */
responsesAvailable: boolean;
/** Per recipient who has responded: their decision and when, from acceptance/{login}. */
responses: Record<string, { action: "accepted" | "rejected"; timestamp: number }>;
}

/** Per-project view for the donor's change UI, from {@link EnvelopeData.projects}. */
/** Per-project view for the donor's change UI, from the envelope's `projects` payload; empty
* for a payload that carries no project. */
function envelopeProjects(data: EnvelopeData): OutgoingShare["projects"] {
return Object.values(data.projects).map((p) => ({
return Object.values(envelopeProjectMap(data)).map((p) => ({
projectId: p.source,
label: p.label,
updatedAt: p.updatedAt,
Expand All @@ -57,6 +69,9 @@ export interface PendingShare {
sender: string; // EnvelopeData.sender, display only
title: string; // display name shown to recipients; defaults to the first project's name
mode: EnvelopeMode; // v1 renders only "copy" entries
/** What the offer carries, so the recipient is told what they are being offered — projects to
* copy, or a template for their own shelf. */
payloadKind: EnvelopePayloadKind;
grantedAt: number;
}

Expand Down Expand Up @@ -108,8 +123,8 @@ export function createOutgoingSharesComputable(
if (envelope === undefined) continue;
if (!resourceTypesEqual(envelope.resourceType, SharedEnvelopeResourceType)) continue;

const data = envelope.getDataAsJson<EnvelopeData>();
if (data === undefined) continue;
const data = normalizeEnvelopeData(envelope.getDataAsJson<unknown>());
if (data === undefined) continue; // unknown version or payload kind — not ours to show

const responses: OutgoingShare["responses"] = {};
for (const f of envelope.listDynamicFields()) {
Expand All @@ -127,7 +142,17 @@ export function createOutgoingSharesComputable(
...(data.expiresAt !== null ? { expiresAt: data.expiresAt } : {}),
mode: data.mode,
title: data.title,
payloadKind: data.payload.kind,
projects: envelopeProjects(data),
...(data.payload.kind === "template"
? {
template: {
label: data.payload.label,
blockCount: data.payload.document.blocks.length,
},
}
: {}),
responsesAvailable: data.payload.kind !== "template",
responses,
envelopeRid: envelope.id,
});
Expand Down Expand Up @@ -255,7 +280,9 @@ export function createLiveEnvelopesComputable(
for (const envelope of roots) {
if (envelope === undefined) continue;
if (!resourceTypesEqual(envelope.resourceType, SharedEnvelopeResourceType)) continue;
const data = envelope.getDataAsJson<EnvelopeData>();
const data = normalizeEnvelopeData(envelope.getDataAsJson<unknown>());
// Same recognise-or-hide rule as the pending view, and for the same envelope: hiding an
// offer while accept could still resolve it would only move the problem.
if (data === undefined) continue;
result.push({ rid: envelope.id, data });
}
Expand Down Expand Up @@ -292,7 +319,9 @@ export function createPendingSharesComputable(
for (const envelope of roots) {
if (envelope === undefined) continue;
if (!resourceTypesEqual(envelope.resourceType, SharedEnvelopeResourceType)) continue;
const data = envelope.getDataAsJson<EnvelopeData>();
const data = normalizeEnvelopeData(envelope.getDataAsJson<unknown>());
// An envelope whose schemaVersion or payload kind this build does not know is hidden, not
// offered: there is nothing useful to do with a share we cannot read.
if (data === undefined) continue;
if (handled.has(data.shareId)) continue; // already accepted or rejected
if (currentUserLogin !== null && data.sender === currentUserLogin) continue; // own share
Expand All @@ -303,6 +332,7 @@ export function createPendingSharesComputable(
sender: data.sender,
title: data.title,
mode: data.mode,
payloadKind: data.payload.kind,
grantedAt: data.sharedAt,
});
}
Expand Down
177 changes: 177 additions & 0 deletions lib/node/pl-middle-layer/src/middle_layer/template_list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import type { PruningFunction } from "@milaboratories/pl-tree";
import { SynchronizedTreeState } from "@milaboratories/pl-tree";
import type {
Filter,
PlClient,
PlTransaction,
ResourceType,
SignedResourceId,
} from "@milaboratories/pl-client";
import {
field,
isNullSignedResourceId,
resourceIdToString,
resourceTypesEqual,
treeFilter,
} from "@milaboratories/pl-client";
import type { TreeAndComputableU } from "./types";
import { Computable } from "@milaboratories/computable";
import type { MiddleLayerEnvironment } from "./middle_layer";
import { notEmpty } from "@milaboratories/ts-helpers";
import type { Branded, ProjectTemplateV1 } from "@milaboratories/pl-model-common";
import type { TemplateExportProblem } from "../model/template_export";
import type { TemplateShareProblem } from "../model/template_share";
import type { ShareId } from "../model/sharing_model";
import type { AppliedEntry, TemplateApplyProblem } from "../model/template_apply";
import type { ProjectId } from "../model/project_model";

export const TemplatesField = "templates";
export const TemplatesResourceType: ResourceType = { name: "Templates", version: "1" };
export const TemplateResourceType: ResourceType = { name: "UserTemplate", version: "1" };

/** Mutable: the only part of a stored template a rename may touch. */
export const TemplateLabelKey = "TemplateLabel";
export const TemplateCreatedTimestamp = "TemplateCreated";

/**
* Unique template identifier in middle layer, the stringified signed resource id of the
* `UserTemplate`. Branded so it cannot be confused with a {@link ProjectId} — both are
* stringified resource ids and every template method takes one of them.
*/
export type TemplateId = Branded<string, "TemplateId">;

/**
* Immutable `data` on a UserTemplate: the document plus what was true when it was taken.
*
* The document lives here and never in KV: KV is listed and fully re-read on every poll,
* while resource data syncs incrementally, and a template document is a multi-kilobyte
* value that never changes.
*/
export interface StoredTemplateData {
schemaVersion: 1;
document: ProjectTemplateV1;
/** Provenance, display only; absent for a template that arrived as a share. */
sourceProjectLabel?: string;
/** Login of the sender, when it arrived as a share. */
sender?: string;
}

/** Decodes the immutable `data` blob of a `UserTemplate` read through a transaction. The
* single raw-decode site; the tree side reads the same JSON with `getDataAsJson`. */
export function decodeStoredTemplateData(data: Uint8Array): StoredTemplateData {
return JSON.parse(Buffer.from(data).toString("utf-8")) as StoredTemplateData;
}

/** One template as the template list surfaces it. */
export interface TemplateListEntry {
/** Unique template identifier in middle layer. Use to operate with the given template. */
id: TemplateId;
/** The mutable label, the only part a rename changes. */
label: string;
created: Date;
/** Number of blocks the stored document lists — derived, not stored. */
blockCount: number;
sourceProjectLabel?: string;
sender?: string;
}

/** What saving a project as a template yields: the stored template, or every block in the way. */
export type SaveProjectAsTemplateOutcome =
| { readonly ok: true; readonly templateId: TemplateId }
| { readonly ok: false; readonly problems: readonly TemplateExportProblem[] };

/** What sharing a stored template yields: the share's logical id, or every entry in the way. */
export type ShareTemplateOutcome =
| { readonly ok: true; readonly shareId: ShareId }
| { readonly ok: false; readonly problems: readonly TemplateShareProblem[] };

/**
* What applying a stored template yields.
*
* `ok: false` carries no project id because no project was created: nothing is written
* until every entry has an installable block.
*/
export type CreateProjectFromTemplateOutcome =
| {
readonly ok: true;
readonly projectId: ProjectId;
readonly added: readonly AppliedEntry[];
}
| { readonly ok: false; readonly problems: readonly TemplateApplyProblem[] };

/**
* Resolves the templates-list resource on the transaction's client root, lazily creating (and
* locking) an empty one when the {@link TemplatesField} is not yet populated. Returns its signed
* id. Used when writing into a root that may have no templates list yet, e.g. a template landing
* in a recipient's root.
*/
export async function ensureTemplateListRid(tx: PlTransaction): Promise<SignedResourceId> {
const templatesField = field(tx.clientRoot, TemplatesField);
tx.createField(templatesField, "Dynamic");
const fData = await tx.getField(templatesField);
if (isNullSignedResourceId(fData.value)) {
const ref = tx.createEphemeral(TemplatesResourceType);
tx.lock(ref);
tx.setField(templatesField, ref);
return await ref.globalId;
}
return fData.value;
}

export const TemplatesListTreePruningFunction: PruningFunction = (resource) => {
if (!resourceTypesEqual(resource.type, TemplatesResourceType)) return [];
return resource.fields;
};

export const templatesListFieldFilter: Filter = treeFilter.resourceTypeEq(
TemplatesResourceType.name,
);

export async function createTemplateList(
pl: PlClient,
rid: SignedResourceId,
env: MiddleLayerEnvironment,
): Promise<TreeAndComputableU<TemplateListEntry[]>> {
const tree = await SynchronizedTreeState.init(
pl,
rid,
{
...env.ops.defaultTreeOptions,
pruning: TemplatesListTreePruningFunction,
fieldFilter: templatesListFieldFilter,
},
env.logger,
);

const c = Computable.make((ctx) => {
const node = ctx.accessor(tree.entry()).node();
if (node === undefined) return undefined;
const result: TemplateListEntry[] = [];

// Templates list resource keeps templates assigned to fields. Each field name is a UUID
for (const field of node.listDynamicFields()) {
const tpl = node.traverse(field);
if (tpl === undefined) continue;
const data = tpl.getDataAsJson<StoredTemplateData>();
// A template whose data has not synced yet is not an entry with unknown content —
// it is an entry we cannot describe at all, so it stays out of the list until it has.
if (data === undefined) continue;
const label = notEmpty(tpl.getKeyValueAsJson<string>(TemplateLabelKey));
const created = notEmpty(tpl.getKeyValueAsJson<number>(TemplateCreatedTimestamp));
result.push({
id: resourceIdToString(tpl.id) as TemplateId,
label,
created: new Date(created),
blockCount: data.document.blocks.length,
...(data.sourceProjectLabel !== undefined
? { sourceProjectLabel: data.sourceProjectLabel }
: {}),
...(data.sender !== undefined ? { sender: data.sender } : {}),
});
}
result.sort((a, b) => b.created.valueOf() - a.created.valueOf());
return result;
}).withStableType();

return { computable: c, tree };
}
Loading
Loading