Skip to content
Merged
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
14 changes: 14 additions & 0 deletions .changeset/proud-otters-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'confluence.js': patch
---

Types now describe the fields Confluence returns that its own spec never documented.

The schema audit shipped in the previous release found 161 undocumented fields across 93 endpoints — keys the API sends
at runtime that the published types hid. This release adds 154 of them into the types; the remaining 7 sit behind
inline or collection schemas and are tracked separately.

Every addition is optional and additive. Nothing that used to type-check stops doing so, runtime validation is
unchanged, and no field is renamed or removed — consumers simply gain access in the types to values they were already
receiving: `_links` keys on every entity, the `Container` and `Version` fields, user `accountStatus`/`locale`, comment
`resolutionStatus`, and more.
40 changes: 40 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,43 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

# changesets/action tags and releases only the packages it detects as
# published, and it detects them by grepping the publish command's stdout for
# the "New tag:" lines that only `changeset publish` prints — never `pnpm
# publish`. So the publish path uploads to npm but leaves no tag behind, and
# v3.0.0 had to be tagged by hand. This step closes that gap without touching
# the known-good `pnpm publish --provenance` command.
#
# It fires on every master push, so it tells the two paths apart itself:
# - changesets still pending → the action only opened/updated the version
# PR; nothing was published, so do nothing.
# - none pending → the version commit merged and was published,
# so tag it. Idempotent: skips when the tag is
# already there, so a re-run cannot double-tag.
- name: 🏷️ Tag and release the published version
if: steps.changesets.outputs.published != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
pending="$(find .changeset -maxdepth 1 -name '*.md' ! -name 'README.md' | wc -l | tr -d ' ')"
if [ "${pending}" != "0" ]; then
echo "Changesets still pending — the action opened the version PR, nothing published. Nothing to tag."
exit 0
fi

version="$(node -p "require('./package.json').version")"
tag="v${version}"

if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
echo "Tag ${tag} already exists — nothing to do."
exit 0
fi

awk -v h="## ${version}" '$0==h{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md > /tmp/release-notes.md
if [ -s /tmp/release-notes.md ]; then
gh release create "${tag}" --target "${GITHUB_SHA}" --title "${tag}" --notes-file /tmp/release-notes.md
else
echo "No CHANGELOG section for ${version}; falling back to generated notes."
gh release create "${tag}" --target "${GITHUB_SHA}" --title "${tag}" --generate-notes
fi
14 changes: 11 additions & 3 deletions scripts/schemaAudit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface SchemaDrift {
endpoint: string;
path: string;
keys: string[];
types: Record<string, string>;
}

// `--report-only` rebuilds the summary from the last run's findings. The suite takes
Expand Down Expand Up @@ -73,6 +74,7 @@ function normalizeEndpoint(endpoint: string): string {
}

const byField = new Map<string, Set<string>>();
const typesByField = new Map<string, Set<string>>();

if (existsSync(findings)) {
const lines = readFileSync(findings, 'utf8').trim();
Expand All @@ -86,6 +88,10 @@ if (existsSync(findings)) {
if (!byField.has(field)) byField.set(field, new Set());

byField.get(field)!.add(normalizeEndpoint(entry.endpoint));

if (!typesByField.has(field)) typesByField.set(field, new Set());

typesByField.get(field)!.add(entry.types?.[key] ?? 'unknown');
}
}
}
Expand All @@ -107,15 +113,17 @@ if (ranked.length === 0) {
'the published spec has fallen behind the API — but each one is a field consumers cannot see',
'in the types.',
'',
'| Field | Endpoints | Seen at |',
'| --- | ---: | --- |',
'| Field | Type | Endpoints | Seen at |',
'| --- | --- | ---: | --- |',
);

for (const [field, seen] of ranked) {
const sample = [...seen].sort().slice(0, 3).join('<br>');
const more = seen.size > 3 ? `<br>…and ${seen.size - 3} more` : '';

summary.push(`| \`${field}\` | ${seen.size} | ${sample}${more} |`);
const types = [...(typesByField.get(field) ?? new Set(['unknown']))].sort().join(' | ');

summary.push(`| \`${field}\` | \`${types}\` | ${seen.size} | ${sample}${more} |`);
}
}

Expand Down
40 changes: 33 additions & 7 deletions src/core/createClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,26 +29,52 @@ async function isScopeMismatchResponse(response: Response): Promise<boolean> {
}
}

/** What an undocumented value looks like, in the vocabulary a schema would use to describe it. */
function describeValue(value: unknown): string {
if (value === null) return 'null';

if (Array.isArray(value)) {
const elements = new Set(value.slice(0, 10).map(element => describeValue(element)));

return elements.size === 0 ? 'unknown[]' : `${[...elements].sort().join(' | ')}[]`;
}

return typeof value;
}

/**
* Removes the keys an audit run found undocumented, so the response can be parsed a second time.
* Reads the undocumented values, then removes them so the response can be parsed a second time.
*
* Audit-only, and it reports the types because the point of the audit is to write the missing field into the schema —
* which cannot be done from a list of names. Guessing `contributorIds` is an array of strings from its name alone is
* how a schema ends up wrong in a new way.
*
* Audit-only. `path` is a zod issue path, so every segment is an object key or an array index, and anything that is
* not there any more is simply skipped — the walk describes a body that was just parsed, not an arbitrary structure.
* `path` is a zod issue path, so every segment is an object key or an array index, and anything no longer there is
* simply skipped — the walk describes a body that was just parsed, not an arbitrary structure.
*/
function dropKeys(body: unknown, path: readonly PropertyKey[], keys: readonly PropertyKey[]): void {
function takeKeys(
body: unknown,
path: readonly PropertyKey[],
keys: readonly PropertyKey[],
): Record<string, string> {
let target = body;

for (const segment of path) {
if (target === null || typeof target !== 'object') return;
if (target === null || typeof target !== 'object') return {};

target = (target as Record<PropertyKey, unknown>)[segment];
}

if (target === null || typeof target !== 'object') return;
if (target === null || typeof target !== 'object') return {};

const types: Record<string, string> = {};

for (const key of keys) {
types[String(key)] = describeValue((target as Record<PropertyKey, unknown>)[key]);
delete (target as Record<PropertyKey, unknown>)[key];
}

return types;
}

interface DriftFinding {
Expand Down Expand Up @@ -354,8 +380,8 @@ export function createClient(config: ClientConfig | Client): Client {
endpoint,
path: finding.path.join('.'),
keys: finding.keys.map(key => String(key)),
types: takeKeys(data, finding.path, finding.keys),
});
dropKeys(data, finding.path, finding.keys);
}

// Re-parsed rather than returned raw, so the caller still gets what
Expand Down
2 changes: 2 additions & 0 deletions src/core/schemaAudit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface SchemaDrift {
path: string;
/** The keys the API sent that the schema does not describe. */
keys: string[];
/** What each of those keys actually held, so the missing field can be written into the schema. */
types: Record<string, string>;
}

type Store = { [STORE]?: SchemaDrift[] };
Expand Down
2 changes: 2 additions & 0 deletions src/v1/models/bulkUserLookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export const BulkUserLookupSchema = apiObject({
personalSpace: z.string().optional(),
}),
_links: GenericLinksSchema,
accountStatus: z.string().optional(),
locale: z.string().optional(),
});

export type BulkUserLookup = z.infer<typeof BulkUserLookupSchema>;
16 changes: 14 additions & 2 deletions src/v1/models/container.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import type { z } from 'zod';
import { z } from 'zod';
import { apiObject } from '#/core';
import { GenericLinksSchema } from './genericLinks';
/**
* Container for content. This can be either a space (containing a page or blogpost)* or a page/blog post (containing an
* attachment or comment)
*/

export const ContainerSchema = apiObject({});
export const ContainerSchema = apiObject({
id: z.string().optional(),
type: z.string().optional(),
status: z.string().optional(),
title: z.string().optional(),
ari: z.string().optional(),
base64EncodedAri: z.string().optional(),
extensions: z.record(z.string(), z.any()).optional(),
macroRenderedOutput: z.record(z.string(), z.any()).optional(),
_links: GenericLinksSchema.optional(),
_expandable: z.record(z.string(), z.any()).optional(),
});

export type Container = z.infer<typeof ContainerSchema>;
6 changes: 6 additions & 0 deletions src/v1/models/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,11 @@ export type Content = {
schedulePublishDate?: string;
schedulePublishInfo?: string;
macroRenderedOutput?: string;
draftVersion?: string;
};
_links?: GenericLinks;
ari?: string;
base64EncodedAri?: string;
};
/** Base object for all content types. */

Expand Down Expand Up @@ -156,6 +159,9 @@ export const ContentSchema: z.ZodType<Content> = apiObject({
schedulePublishDate: z.string().optional(),
schedulePublishInfo: z.string().optional(),
macroRenderedOutput: z.string().optional(),
draftVersion: z.string().optional(),
}).optional(),
_links: GenericLinksSchema.optional(),
ari: z.string().optional(),
base64EncodedAri: z.string().optional(),
});
2 changes: 2 additions & 0 deletions src/v1/models/contentChildren.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type ContentChildren = {
database?: string;
embed?: string;
folder?: string;
slide?: string;
};
_links?: GenericLinks;
};
Expand All @@ -39,6 +40,7 @@ export const ContentChildrenSchema: z.ZodType<ContentChildren> = apiObject({
database: z.string().optional(),
embed: z.string().optional(),
folder: z.string().optional(),
slide: z.string().optional(),
}).optional(),
_links: GenericLinksSchema.optional(),
});
4 changes: 4 additions & 0 deletions src/v1/models/contentMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export type ContentMetadata = {
properties?: GenericLinks;
frontend?: Record<string, unknown>;
labels?: LabelArray | Label[];
mediaType?: string;
_expandable?: Record<string, unknown>;
};
/** Metadata object for page, blogpost, comment content */

Expand Down Expand Up @@ -68,4 +70,6 @@ export const ContentMetadataSchema: z.ZodType<ContentMetadata> = apiObject({
properties: GenericLinksSchema.optional(),
frontend: z.record(z.string(), z.any()).optional(),
labels: z.union([LabelArraySchema, z.array(LabelSchema)]).optional(),
mediaType: z.string().optional(),
_expandable: z.record(z.string(), z.any()).optional(),
});
29 changes: 27 additions & 2 deletions src/v1/models/genericLinks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
import type { z } from 'zod';
import { z } from 'zod';
import { apiObject } from '#/core';

export const GenericLinksSchema = apiObject({});
export const GenericLinksSchema = apiObject({
/** Base URL of the Confluence site. */
base: z.string().optional(),
/** Link to the restrictions grouped by operation. */
byOperation: z.string().optional(),
/** Link to the collection this entity belongs to. */
collection: z.string().optional(),
/** Context path of the Confluence site. */
context: z.string().optional(),
/** Download link for the content. */
download: z.string().optional(),
/** Edit UI link of the content. */
editui: z.string().optional(),
/** Edit UI (v2 editor) link of the content. */
edituiv2: z.string().optional(),
/** Relative URL of the next page of results. */
next: z.string().optional(),
/** Relative URL of the previous page of results. */
prev: z.string().optional(),
/** Canonical link to this entity. */
self: z.string().optional(),
/** Short web UI link of the content. */
tinyui: z.string().optional(),
/** Web UI link of the content. */
webui: z.string().optional(),
});

export type GenericLinks = z.infer<typeof GenericLinksSchema>;
1 change: 1 addition & 0 deletions src/v1/models/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const GroupSchema = apiObject({
*/
managedBy: z.enum(['ADMINS', 'EXTERNAL', 'TEAM_MEMBERS', 'OPEN']).optional(),
_links: GenericLinksSchema.optional(),
resourceAri: z.string().optional(),
});

export type Group = z.infer<typeof GroupSchema>;
1 change: 1 addition & 0 deletions src/v1/models/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { apiObject } from '#/core';
export const MessageSchema = apiObject({
translation: z.string().optional(),
args: z.array(z.union([z.string(), z.record(z.string(), z.any())])),
key: z.string().optional(),
});

export type Message = z.infer<typeof MessageSchema>;
6 changes: 6 additions & 0 deletions src/v1/models/space.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,11 @@ export type Space = {
history?: string;
homepage?: string;
identifiers?: string;
roles?: string;
typeSettings?: string;
};
_links: GenericLinks;
ari?: string;
};

export const SpaceSchema: z.ZodType<Space> = apiObject({
Expand Down Expand Up @@ -100,6 +103,9 @@ export const SpaceSchema: z.ZodType<Space> = apiObject({
history: z.string().optional(),
homepage: z.string().optional(),
identifiers: z.string().optional(),
roles: z.string().optional(),
typeSettings: z.string().optional(),
}),
_links: GenericLinksSchema,
ari: z.string().optional(),
});
4 changes: 4 additions & 0 deletions src/v1/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export type User = {
personalSpace?: string;
};
_links?: GenericLinks;
accountStatus?: string;
locale?: string;
};

export const UserSchema: z.ZodType<User> = apiObject({
Expand Down Expand Up @@ -68,4 +70,6 @@ export const UserSchema: z.ZodType<User> = apiObject({
personalSpace: z.string().optional(),
}).optional(),
_links: GenericLinksSchema.optional(),
accountStatus: z.string().optional(),
locale: z.string().optional(),
});
3 changes: 3 additions & 0 deletions src/v1/models/userAnonymous.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export const UserAnonymousSchema = apiObject({
operations: z.string().optional(),
}).optional(),
_links: GenericLinksSchema,
accountStatus: z.string().optional(),
locale: z.string().optional(),
isExternalCollaborator: z.boolean().optional(),
});

export type UserAnonymous = z.infer<typeof UserAnonymousSchema>;
4 changes: 4 additions & 0 deletions src/v1/models/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export type Version = {
confRev?: string | null;
syncRev?: string | null;
syncRevSource?: string | null;
ncsStepVersion?: string;
ncsStepVersionSource?: string;
};

export const VersionSchema: z.ZodType<Version> = apiObject({
Expand All @@ -49,4 +51,6 @@ export const VersionSchema: z.ZodType<Version> = apiObject({
syncRev: z.string().nullish(),
/** Source of the synchrony revision */
syncRevSource: z.string().nullish(),
ncsStepVersion: z.string().optional(),
ncsStepVersionSource: z.string().optional(),
});
3 changes: 3 additions & 0 deletions src/v1/models/watchUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export const WatchUserSchema = apiObject({
email: z.string(),
publicName: z.string(),
personalSpace: z.record(z.string(), z.any()).nullable(),
accountStatus: z.string().optional(),
locale: z.string().optional(),
guest: z.boolean().optional(),
});

export type WatchUser = z.infer<typeof WatchUserSchema>;
Loading