docsmith 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rspec_status +212 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +66 -0
- data/Rakefile +8 -0
- data/USAGE.md +510 -0
- data/docs/superpowers/plans/2026-04-01-docsmith-full-plan.md +6459 -0
- data/docs/superpowers/plans/2026-04-08-parsers-remove-branches-docs.md +2112 -0
- data/docs/superpowers/specs/2026-04-01-docsmith-phase1-design.md +340 -0
- data/docsmith_spec.md +630 -0
- data/lib/docsmith/auto_save.rb +29 -0
- data/lib/docsmith/comments/anchor.rb +68 -0
- data/lib/docsmith/comments/comment.rb +44 -0
- data/lib/docsmith/comments/manager.rb +73 -0
- data/lib/docsmith/comments/migrator.rb +64 -0
- data/lib/docsmith/configuration.rb +95 -0
- data/lib/docsmith/diff/engine.rb +39 -0
- data/lib/docsmith/diff/parsers/html.rb +64 -0
- data/lib/docsmith/diff/parsers/markdown.rb +60 -0
- data/lib/docsmith/diff/renderers/base.rb +62 -0
- data/lib/docsmith/diff/renderers/registry.rb +41 -0
- data/lib/docsmith/diff/renderers.rb +10 -0
- data/lib/docsmith/diff/result.rb +77 -0
- data/lib/docsmith/diff.rb +6 -0
- data/lib/docsmith/document.rb +44 -0
- data/lib/docsmith/document_version.rb +50 -0
- data/lib/docsmith/errors.rb +18 -0
- data/lib/docsmith/events/event.rb +19 -0
- data/lib/docsmith/events/hook_registry.rb +14 -0
- data/lib/docsmith/events/notifier.rb +22 -0
- data/lib/docsmith/rendering/html_renderer.rb +36 -0
- data/lib/docsmith/rendering/json_renderer.rb +29 -0
- data/lib/docsmith/version.rb +5 -0
- data/lib/docsmith/version_manager.rb +143 -0
- data/lib/docsmith/version_tag.rb +25 -0
- data/lib/docsmith/versionable.rb +252 -0
- data/lib/docsmith.rb +52 -0
- data/lib/generators/docsmith/install/install_generator.rb +27 -0
- data/lib/generators/docsmith/install/templates/create_docsmith_tables.rb.erb +64 -0
- data/lib/generators/docsmith/install/templates/docsmith_initializer.rb.erb +19 -0
- data/sig/docsmith.rbs +4 -0
- metadata +196 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# Docsmith Phase 1 — Core Versioning Design
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-04-01
|
|
4
|
+
**Scope:** Phase 1 only — snapshots, restore, tags. No diff rendering, comments, or branching.
|
|
5
|
+
**Status:** Approved
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Background
|
|
10
|
+
|
|
11
|
+
Docsmith is a plug-and-play document version manager gem for Rails. Phase 1 establishes the
|
|
12
|
+
storage model, the `Versionable` mixin, the standalone `Document` API, and the events system.
|
|
13
|
+
All later phases build on top of this foundation without changing the core tables.
|
|
14
|
+
|
|
15
|
+
**Key design decisions carried in from spec:**
|
|
16
|
+
- Full snapshots (not deltas) — storage is simple, rollback is trivial, diff algorithm is swappable later.
|
|
17
|
+
- Pure-Ruby `diff-lcs` — no system dependencies (Phase 2).
|
|
18
|
+
- Config precedence: per-class `docsmith_config` > global `Docsmith.configure` > gem defaults.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Section 1: Database Schema
|
|
23
|
+
|
|
24
|
+
### `docsmith_documents`
|
|
25
|
+
|
|
26
|
+
The central record for every versioned document, whether created standalone or via the mixin.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
docsmith_documents
|
|
30
|
+
id :bigint, PK
|
|
31
|
+
title :string
|
|
32
|
+
content :text # live content field; save_version! snapshots from here
|
|
33
|
+
content_type :string # "html", "markdown", "json"
|
|
34
|
+
versions_count :integer, default: 0 # renamed from current_version (naming collision avoided)
|
|
35
|
+
last_versioned_at :datetime # tracks debounce window for auto_save_version!
|
|
36
|
+
subject_type :string # polymorphic — set when created via Versionable mixin
|
|
37
|
+
subject_id :bigint # polymorphic — the originating AR record's id
|
|
38
|
+
metadata :jsonb, default: {}
|
|
39
|
+
created_at :datetime
|
|
40
|
+
updated_at :datetime
|
|
41
|
+
|
|
42
|
+
index: [subject_type, subject_id]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### `docsmith_versions`
|
|
46
|
+
|
|
47
|
+
Full content snapshots. Diffs are computed on read (Phase 2), never stored.
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
docsmith_versions
|
|
51
|
+
id :bigint, PK
|
|
52
|
+
document_id :bigint, FK -> docsmith_documents
|
|
53
|
+
version_number :integer # 1-indexed, sequential per document
|
|
54
|
+
content :text # full snapshot at save time
|
|
55
|
+
content_type :string # inherited from document at save time
|
|
56
|
+
author_type :string # polymorphic author
|
|
57
|
+
author_id :bigint
|
|
58
|
+
change_summary :string, nullable
|
|
59
|
+
metadata :jsonb, default: {}
|
|
60
|
+
created_at :datetime
|
|
61
|
+
|
|
62
|
+
index: [document_id, version_number], unique: true
|
|
63
|
+
index: [author_type, author_id]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**AR model class:** `Docsmith::DocumentVersion` (`self.table_name = "docsmith_versions"`)
|
|
67
|
+
Note: class is `DocumentVersion` (not `Version`) to avoid collision with `lib/docsmith/version.rb`
|
|
68
|
+
which holds the `Docsmith::VERSION` gem constant.
|
|
69
|
+
|
|
70
|
+
### `docsmith_version_tags`
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
docsmith_version_tags
|
|
74
|
+
id :bigint, PK
|
|
75
|
+
document_id :bigint, FK -> docsmith_documents # denormalized for unique constraint
|
|
76
|
+
version_id :bigint, FK -> docsmith_versions
|
|
77
|
+
name :string
|
|
78
|
+
author_type :string
|
|
79
|
+
author_id :bigint
|
|
80
|
+
created_at :datetime
|
|
81
|
+
|
|
82
|
+
index: [document_id, name], unique: true # tag names are unique per document
|
|
83
|
+
index: [version_id]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`document_id` is denormalized (also reachable via `version.document_id`) to allow a DB-level
|
|
87
|
+
unique constraint on `[document_id, name]`. This enforces that a tag name like "v1.0-release"
|
|
88
|
+
can only exist once across all versions of a document.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Section 2: Mixin API & Behavior Contracts
|
|
93
|
+
|
|
94
|
+
### Including the mixin
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
class Article < ApplicationRecord
|
|
98
|
+
include Docsmith::Versionable
|
|
99
|
+
|
|
100
|
+
docsmith_config do
|
|
101
|
+
content_field :body # attribute to snapshot — must return a plain String
|
|
102
|
+
content_type :html # :html, :markdown, :json
|
|
103
|
+
auto_save true
|
|
104
|
+
debounce 60.seconds # overrides global default
|
|
105
|
+
max_versions nil # nil = unlimited
|
|
106
|
+
content_extractor nil # optional: ->(record) { record.body.to_html }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Every key in `docsmith_config` is optional. Resolution order for **every** setting, without
|
|
112
|
+
exception:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
per-class docsmith_config → global Docsmith.configure → gem defaults
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Resolution happens at read time (when a setting is accessed), not at definition time.
|
|
119
|
+
Changing global config after class definition takes effect for any key the class does not override.
|
|
120
|
+
|
|
121
|
+
### How the mixin hooks into ActiveRecord
|
|
122
|
+
|
|
123
|
+
1. Registers an `after_save` callback that calls `auto_save_version!` when `auto_save: true`.
|
|
124
|
+
2. The shadow `Docsmith::Document` row is created **lazily** on the first `save_version!` /
|
|
125
|
+
`auto_save_version!` call via `find_or_create_by(subject: self)`.
|
|
126
|
+
3. The shadow document is cached in `@docsmith_document` after first lookup to avoid repeated queries.
|
|
127
|
+
|
|
128
|
+
### Public method contracts
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
# Creates a new DocumentVersion snapshot.
|
|
132
|
+
# Returns the DocumentVersion on success.
|
|
133
|
+
# Returns nil if content is identical to the latest version (simple string == check for v1).
|
|
134
|
+
# Raises Docsmith::InvalidContentField if content_field does not return a String
|
|
135
|
+
# (unless content_extractor is configured — its result is used instead).
|
|
136
|
+
article.save_version!(author: user, summary: "Fixed intro")
|
|
137
|
+
article.save_version!(author: user) # summary is optional
|
|
138
|
+
|
|
139
|
+
# Debounced auto-save. Returns nil if debounce window has not elapsed OR content is unchanged.
|
|
140
|
+
# Returns the DocumentVersion on success. Both skip reasons return nil (no distinction needed).
|
|
141
|
+
article.auto_save_version!(author: user)
|
|
142
|
+
|
|
143
|
+
# AR relation of all DocumentVersions for this record. Orderable and chainable.
|
|
144
|
+
article.versions
|
|
145
|
+
|
|
146
|
+
# The latest DocumentVersion (highest version_number).
|
|
147
|
+
article.current_version # => Docsmith::DocumentVersion
|
|
148
|
+
|
|
149
|
+
# A specific DocumentVersion by version_number (1-indexed). Returns nil if not found.
|
|
150
|
+
article.version(3) # => Docsmith::DocumentVersion | nil
|
|
151
|
+
|
|
152
|
+
# Creates a new version whose content is copied from version N. Never mutates history.
|
|
153
|
+
# Returns the new DocumentVersion.
|
|
154
|
+
article.restore_version!(3, author: user)
|
|
155
|
+
|
|
156
|
+
# Tags a specific version by version_number. Raises if the name is already taken
|
|
157
|
+
# on this document (unique per document, not just per version).
|
|
158
|
+
article.tag_version!(3, name: "v1.0-release", author: user)
|
|
159
|
+
|
|
160
|
+
# Returns the DocumentVersion that carries this tag, or nil.
|
|
161
|
+
article.tagged_version("v1.0-release") # => Docsmith::DocumentVersion | nil
|
|
162
|
+
|
|
163
|
+
# Returns array of tag name strings for version N.
|
|
164
|
+
article.version_tags(3) # => ["v1.0-release", "draft"]
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### `max_versions` pruning
|
|
168
|
+
|
|
169
|
+
When `max_versions` is configured and a new version would exceed the limit:
|
|
170
|
+
1. Delete the oldest version that has **no tags**. Tagged versions are exempt (they are pinned).
|
|
171
|
+
2. If all versions are tagged and the limit is still exceeded, raise `Docsmith::MaxVersionsExceeded`.
|
|
172
|
+
3. When `max_versions: nil` (gem default), no pruning occurs — unlimited history.
|
|
173
|
+
|
|
174
|
+
### Content validation
|
|
175
|
+
|
|
176
|
+
Before any snapshot, Docsmith reads the content via:
|
|
177
|
+
- `content_extractor.call(record)` if a proc is configured (per-class or global), otherwise
|
|
178
|
+
- `record.send(content_field)`
|
|
179
|
+
|
|
180
|
+
If the result is not a `String`, raises `Docsmith::InvalidContentField` with a message that
|
|
181
|
+
points the user to the `content_extractor` option.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Section 3: Internal Architecture
|
|
186
|
+
|
|
187
|
+
### `Docsmith::VersionManager`
|
|
188
|
+
|
|
189
|
+
All mixin methods delegate here. The mixin is a thin API surface; `VersionManager` owns the logic.
|
|
190
|
+
Always receives a `Docsmith::Document` instance — the mixin resolves the shadow document first.
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
Docsmith::VersionManager.save!(document, author:, summary: nil)
|
|
194
|
+
# Reads document.content, compares to latest version content using simple string == (v1).
|
|
195
|
+
# Returns nil if identical (no-op).
|
|
196
|
+
# Inserts DocumentVersion, increments versions_count, updates last_versioned_at.
|
|
197
|
+
# Prunes oldest untagged version if max_versions exceeded (raises if all tagged).
|
|
198
|
+
# Fires :version_created event.
|
|
199
|
+
|
|
200
|
+
Docsmith::VersionManager.restore!(document, version:, author:)
|
|
201
|
+
# Finds DocumentVersion by version_number.
|
|
202
|
+
# Copies its content into document.content, then calls save!.
|
|
203
|
+
# Fires :version_restored event.
|
|
204
|
+
|
|
205
|
+
Docsmith::VersionManager.tag!(document, version:, name:, author:)
|
|
206
|
+
# Finds DocumentVersion by version_number.
|
|
207
|
+
# Creates VersionTag. Raises if name already taken on this document.
|
|
208
|
+
# Fires :version_tagged event.
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### `Docsmith::AutoSave`
|
|
212
|
+
|
|
213
|
+
Extracted into its own class for independent testability. The debounce window calculation is
|
|
214
|
+
exposed publicly so specs can assert on it directly without mocking time.
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
Docsmith::AutoSave.call(document, author:)
|
|
218
|
+
# Checks document.last_versioned_at against configured debounce.
|
|
219
|
+
# Returns nil if within debounce window.
|
|
220
|
+
# Otherwise delegates to VersionManager.save!
|
|
221
|
+
|
|
222
|
+
Docsmith::AutoSave.within_debounce?(document)
|
|
223
|
+
# Returns true if the debounce window has not elapsed. Public for testability.
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### `Docsmith::Configuration`
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
Docsmith::Configuration::DEFAULTS = {
|
|
230
|
+
content_field: :body,
|
|
231
|
+
content_type: :markdown,
|
|
232
|
+
auto_save: true,
|
|
233
|
+
debounce: 30, # integer seconds (not ActiveSupport::Duration — no AS dep here)
|
|
234
|
+
max_versions: nil,
|
|
235
|
+
content_extractor: nil
|
|
236
|
+
}.freeze
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`.resolve(class_config, global_config)` merges per-class over global over defaults at read time.
|
|
240
|
+
No mutation of either config object — returns a plain hash.
|
|
241
|
+
|
|
242
|
+
**Debounce normalization:** `debounce` accepts both `Integer` (seconds) and
|
|
243
|
+
`ActiveSupport::Duration` (e.g., `60.seconds`). The config system normalizes any Duration to
|
|
244
|
+
an integer via `.to_i` at read time, so internal comparisons always use plain integers.
|
|
245
|
+
|
|
246
|
+
### `Docsmith::Events`
|
|
247
|
+
|
|
248
|
+
Every action fires **both** the hook registry and `ActiveSupport::Notifications`. Neither is optional.
|
|
249
|
+
|
|
250
|
+
**Components:**
|
|
251
|
+
- `Docsmith::Events::HookRegistry` — stores procs per event name, calls them synchronously.
|
|
252
|
+
- `Docsmith::Events::Notifier` — wraps `ActiveSupport::Notifications.instrument`.
|
|
253
|
+
- `Docsmith::Events::Event` — struct carrying the payload fields below.
|
|
254
|
+
|
|
255
|
+
**Payload fields by event:**
|
|
256
|
+
|
|
257
|
+
| Event | `record` | `document` | `version` | `author` | extras |
|
|
258
|
+
|--------------------|----------|------------|-----------|----------|-----------------|
|
|
259
|
+
| `version_created` | ✓ | ✓ | ✓ | ✓ | — |
|
|
260
|
+
| `version_restored` | ✓ | ✓ | ✓ | ✓ | `from_version` |
|
|
261
|
+
| `version_tagged` | ✓ | ✓ | ✓ | ✓ | `tag_name` |
|
|
262
|
+
|
|
263
|
+
- `event.record` — the originating AR object (`Article` instance when using mixin,
|
|
264
|
+
`Docsmith::Document` when using standalone API).
|
|
265
|
+
- `event.document` — always the `Docsmith::Document` shadow record.
|
|
266
|
+
|
|
267
|
+
AS::Notifications instrument names: `"version_created.docsmith"`, `"version_restored.docsmith"`,
|
|
268
|
+
`"version_tagged.docsmith"`.
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Section 4: Generator & Test Setup
|
|
273
|
+
|
|
274
|
+
### `rails generate docsmith:install`
|
|
275
|
+
|
|
276
|
+
Produces:
|
|
277
|
+
|
|
278
|
+
**`db/migrate/TIMESTAMP_create_docsmith_tables.rb`**
|
|
279
|
+
Creates all three tables in a single migration. Safe on PostgreSQL, MySQL, and SQLite.
|
|
280
|
+
Column order matches the schema in Section 1 exactly.
|
|
281
|
+
|
|
282
|
+
**`config/initializers/docsmith.rb`**
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
Docsmith.configure do |config|
|
|
286
|
+
# config.default_content_field = :body
|
|
287
|
+
# config.default_content_type = :markdown # :html, :markdown, :json
|
|
288
|
+
# config.auto_save = true
|
|
289
|
+
# config.default_debounce = 30 # integer seconds
|
|
290
|
+
# config.max_versions = nil # nil = unlimited
|
|
291
|
+
# config.content_extractor = nil # ->(record) { record.body.to_html }
|
|
292
|
+
# config.table_prefix = "docsmith"
|
|
293
|
+
# config.diff_context_lines = 3 # used in Phase 2
|
|
294
|
+
end
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Test setup (`spec/support/`)
|
|
298
|
+
|
|
299
|
+
**`spec/support/schema.rb`**
|
|
300
|
+
In-memory SQLite schema that mirrors the migration exactly. Single source of truth for the
|
|
301
|
+
test DB. If the migration changes, this file changes too.
|
|
302
|
+
|
|
303
|
+
**`spec/support/models.rb`**
|
|
304
|
+
Minimal AR models for specs:
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
class Article < ActiveRecord::Base
|
|
308
|
+
include Docsmith::Versionable
|
|
309
|
+
docsmith_config { content_field :body; content_type :markdown }
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
class Post < ActiveRecord::Base
|
|
313
|
+
include Docsmith::Versionable
|
|
314
|
+
# uses all gem defaults
|
|
315
|
+
end
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
**`spec_helper.rb`** additions:
|
|
319
|
+
- Requires `active_record`, `sqlite3`, `factory_bot`.
|
|
320
|
+
- Establishes in-memory SQLite connection before suite.
|
|
321
|
+
- Loads `spec/support/schema.rb` then `spec/support/models.rb`.
|
|
322
|
+
- Wraps each example in a transaction (rollback after each test — no truncation needed).
|
|
323
|
+
- Includes `FactoryBot::Syntax::Methods`.
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## Decisions Log
|
|
328
|
+
|
|
329
|
+
| # | Question | Decision |
|
|
330
|
+
|---|----------|----------|
|
|
331
|
+
| 1 | Mixin + docsmith_documents relationship | A — shadow document with `subject_type/subject_id` on `docsmith_documents`; `from_record` does find-or-create |
|
|
332
|
+
| 2 | `content` column on `docsmith_documents` | B — add `content :text`; live content field; `save_version!` snapshots from it |
|
|
333
|
+
| 3 | `current_version` naming collision | A — rename column to `versions_count`; `current_version` method returns `DocumentVersion` |
|
|
334
|
+
| 4 | AR model class name | C — `Docsmith::DocumentVersion`; avoids collision with `version.rb` gem constant file |
|
|
335
|
+
| 5 | Debounce storage | A — `last_versioned_at :datetime` on `docsmith_documents` |
|
|
336
|
+
| 6 | `save_version!` on identical content | C — returns `nil` (no-op); returns `DocumentVersion` on save |
|
|
337
|
+
| 7 | `max_versions` pruning | C — prune oldest untagged; tagged versions exempt; raise `MaxVersionsExceeded` if all tagged; `nil` default = unlimited |
|
|
338
|
+
| 8 | Event `document` payload | C — `event.record` (originating AR object) + `event.document` (shadow `Docsmith::Document`) |
|
|
339
|
+
| 9 | Non-string content fields | B + C — raise `Docsmith::InvalidContentField` by default; `content_extractor` proc available as opt-in (per-class or global) |
|
|
340
|
+
| 10 | `auto_save_version!` no-op return | B — `nil` for both skip reasons (debounced or unchanged) |
|